feat(observability): add v2 genai tracing
This commit is contained in:
parent
e3a39b214f
commit
0271872c09
42 changed files with 3150 additions and 373 deletions
|
|
@ -1,11 +1,23 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Logger } from "effect"
|
||||
import {
|
||||
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
|
||||
ATTR_OPENCODE_CLIENT,
|
||||
ATTR_OPENCODE_RUN,
|
||||
ATTR_SERVICE_INSTANCE_ID,
|
||||
ATTR_SERVICE_NAMESPACE,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import { Deferred, Effect, Fiber, Layer, Logger, Option, Tracer } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileLogger } from "../../src/observability/logging"
|
||||
import { resource } from "../../src/observability/otlp"
|
||||
import { SessionTelemetry } from "../../src/observability/session"
|
||||
import { HttpTelemetry } from "../../src/observability/http"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||
const opencodeClient = process.env.OPENCODE_CLIENT
|
||||
|
|
@ -20,8 +32,7 @@ afterEach(() => {
|
|||
|
||||
describe("resource", () => {
|
||||
test("parses and decodes OTEL resource attributes", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here`
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"service.namespace": "anomalyco",
|
||||
|
|
@ -32,26 +43,87 @@ describe("resource", () => {
|
|||
})
|
||||
|
||||
test("drops OTEL resource attributes when any entry is invalid", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,broken`
|
||||
|
||||
expect(resource().attributes["service.namespace"]).toBeUndefined()
|
||||
expect(resource().attributes["opencode.client"]).toBeDefined()
|
||||
expect(resource().attributes[ATTR_SERVICE_NAMESPACE]).toBeUndefined()
|
||||
expect(resource().attributes[ATTR_OPENCODE_CLIENT]).toBeDefined()
|
||||
})
|
||||
|
||||
test("keeps built-in attributes when env values conflict", () => {
|
||||
process.env.OPENCODE_CLIENT = "cli"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_OPENCODE_CLIENT}=web,${ATTR_SERVICE_INSTANCE_ID}=override,${ATTR_SERVICE_NAMESPACE}=anomalyco`
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"opencode.client": "cli",
|
||||
"service.namespace": "anomalyco",
|
||||
[ATTR_OPENCODE_CLIENT]: "cli",
|
||||
[ATTR_SERVICE_NAMESPACE]: "anomalyco",
|
||||
})
|
||||
expect(resource().attributes["service.instance.id"]).not.toBe("override")
|
||||
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
|
||||
expect(resource().attributes[ATTR_SERVICE_INSTANCE_ID]).not.toBe("override")
|
||||
expect(resource().attributes[ATTR_OPENCODE_RUN]).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
test("uses deployment environment from OTEL resource attributes", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_DEPLOYMENT_ENVIRONMENT_NAME}=development`
|
||||
|
||||
expect(resource().attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("development")
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("retains an execution trace parent until the execution settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const started = yield* Deferred.make<void>()
|
||||
let parent: Span | undefined
|
||||
|
||||
yield* Effect.useSpan("parent", (span) =>
|
||||
Effect.gen(function* () {
|
||||
parent = span
|
||||
const joiner = yield* telemetry
|
||||
.resume("session", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)))
|
||||
.pipe(Effect.provideService(SessionTelemetry.TraceParent, span), Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(joiner)
|
||||
}),
|
||||
)
|
||||
|
||||
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
expect(retained).toBe(parent)
|
||||
|
||||
yield* telemetry.settled("session")
|
||||
const released = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
expect(released).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains an external ambient trace parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const parent = Tracer.externalSpan({ traceId: "1".repeat(32), spanId: "2".repeat(16) })
|
||||
|
||||
yield* telemetry.resume("session", Effect.void).pipe(Effect.withParentSpan(parent))
|
||||
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
|
||||
expect(retained).toBe(parent)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies HTTP response validation without a parent span", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.get("https://example.test/missing")
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("missing", { status: 404 }))),
|
||||
)
|
||||
|
||||
const exit = yield* HttpTelemetry.use(
|
||||
http,
|
||||
request,
|
||||
Effect.succeed,
|
||||
HttpClientResponse.filterStatusOk,
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
test("falls back to local logging when OTLP initialization fails", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
|
||||
await using _ = {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream, Tracer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
|
|
@ -293,6 +294,14 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
|||
|
||||
it.effect("does not call MCP when permission is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
|
|
@ -308,12 +317,17 @@ it.effect("does not call MCP when permission is blocked", () =>
|
|||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
|
||||
expect(settlement.output?.structured).toEqual({
|
||||
toolCalls: [{ tool: "demo.search", status: "error" }],
|
||||
error: true,
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
const outer = spans.find((span) => span.name === "execute_tool execute")
|
||||
const nested = spans.find((span) => span.name === "execute_tool demo_search")
|
||||
expect(outer?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(nested?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(nested?.parent._tag === "Some" && nested.parent.value === outer).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
|
||||
import {
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
|
@ -39,7 +45,7 @@ import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
|||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Tracer } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -93,6 +99,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -108,7 +122,10 @@ const execution = Layer.effect(
|
|||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
).pipe(
|
||||
Layer.provide(runnerLayer),
|
||||
Layer.updateService(Tracer.Tracer, () => tracer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
|
|
@ -149,6 +166,7 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
|||
describe("SessionRunnerLLM recorded", () => {
|
||||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||
Effect.gen(function* () {
|
||||
spans.length = 0
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
|
|
@ -185,6 +203,14 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
|
||||
{ type: "text", text: "Hello!" },
|
||||
])
|
||||
const agent = spans.find((span) => span.name === "invoke_agent")
|
||||
const model = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_CONVERSATION_ID)).toBe(sessionID)
|
||||
expect(model?.parent._tag === "Some" ? model.parent.value.spanId : undefined).toBe(agent?.spanId)
|
||||
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(model?.spanId)
|
||||
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBeNumber()
|
||||
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBeNumber()
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
|
|
|
|||
|
|
@ -7,8 +7,22 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ATTR_ERROR_TYPE, ATTR_GEN_AI_TOOL_CALL_ID } from "@opencode-ai/core/observability/semconv"
|
||||
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
Option,
|
||||
Schema,
|
||||
SchemaGetter,
|
||||
SchemaIssue,
|
||||
Scope,
|
||||
Tracer,
|
||||
} from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const bounds: ToolOutputStore.BoundInput[] = []
|
||||
|
|
@ -212,6 +226,33 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("traces unknown and stale tool attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
|
||||
yield* materialized.settle(call("missing", "call-unknown")).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
yield* service.register({ echo: make() })
|
||||
yield* materialized.settle(call("echo", "call-stale")).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(spans.map((span) => span.name)).toEqual(["execute_tool missing", "execute_tool echo"])
|
||||
expect(spans[0]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.unknown")
|
||||
expect(spans[0]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-unknown")
|
||||
expect(spans[1]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.stale")
|
||||
expect(spans[1]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-stale")
|
||||
expect(spans.every((span) => span.status._tag === "Ended" && span.status.exit._tag === "Failure")).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
|
|
|||
|
|
@ -8,9 +8,42 @@ import {
|
|||
TransportReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
Usage,
|
||||
type LLMClientShape,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_AGENT_NAME,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_TOOL_CALL_ID,
|
||||
ATTR_GEN_AI_TOOL_NAME,
|
||||
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
|
||||
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
|
||||
ATTR_OPENCODE_AGENT_STEP_INDEX,
|
||||
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
|
||||
ATTR_OPENCODE_COMPACTION_REASON,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_RETRY_DECISION,
|
||||
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
|
||||
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
|
||||
ATTR_OPENCODE_RETRY_ATTEMPT,
|
||||
ATTR_OPENCODE_RETRY_DELAY_MS,
|
||||
ATTR_OPENCODE_SESSION_INPUT_COUNT,
|
||||
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
|
||||
ATTR_OPENCODE_SESSION_PARENT_ID,
|
||||
EVENT_OPENCODE_COMPACTION_COMPLETED,
|
||||
EVENT_OPENCODE_RETRY_SCHEDULED,
|
||||
EVENT_OPENCODE_RETRY_STOPPED,
|
||||
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
|
|
@ -61,7 +94,7 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream, Tracer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -259,6 +292,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[McpGuidance.node, mcpGuidance],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -274,7 +315,10 @@ const execution = Layer.effect(
|
|||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
).pipe(
|
||||
Layer.provide(runnerLayer),
|
||||
Layer.updateService(Tracer.Tracer, () => tracer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
|
|
@ -338,6 +382,7 @@ const insertSession = (id: SessionV2.ID) =>
|
|||
|
||||
const setup = Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
requests.length = 0
|
||||
response = []
|
||||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
|
|
@ -356,6 +401,7 @@ const setup = Effect.gen(function* () {
|
|||
toolExecutionsReady = 5
|
||||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
spans.length = 0
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
|
|
@ -643,6 +689,180 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("parents forked tool spans under the V2 agent turn", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use echo" }), resume: false })
|
||||
const usage = new Usage({
|
||||
inputTokens: 8,
|
||||
outputTokens: 3,
|
||||
cacheReadInputTokens: 2,
|
||||
cacheWriteInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
})
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-telemetry", name: "echo", input: { text: "hello" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage }),
|
||||
LLMEvent.finish({ reason: "tool-calls", usage }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const agent = spans.find((span) => span.name === "invoke_agent")
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(agent?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
|
||||
[ATTR_GEN_AI_AGENT_NAME, "build"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(agent?.events.find(([name]) => name === EVENT_OPENCODE_SESSION_INPUT_PROMOTED)?.[2]).toMatchObject({
|
||||
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: "steer",
|
||||
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: 1,
|
||||
})
|
||||
expect(ancestorNames(agent)).toContain("SessionRunner.drain")
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(8)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(3)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS)).toBe(2)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS)).toBe(1)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS)).toBe(1)
|
||||
expect(tool?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL],
|
||||
[ATTR_GEN_AI_TOOL_NAME, "echo"],
|
||||
[ATTR_GEN_AI_TOOL_CALL_ID, "call-telemetry"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(ancestorNames(tool)).toContain("invoke_agent")
|
||||
expect(ancestorNames(tool)).not.toContain("SessionRunner.attemptStep")
|
||||
expect(ancestorNames(tool)).not.toContain("chat fake-model")
|
||||
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Success")
|
||||
requests.length = 0
|
||||
spans.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tracks V2 subagent sessions with their parent session", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(AgentV2.ID.make("explore"), (agent) => {
|
||||
agent.mode = "subagent"
|
||||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
const child = yield* session.create({
|
||||
id: otherSessionID,
|
||||
parentID: sessionID,
|
||||
agent: AgentV2.ID.make("explore"),
|
||||
})
|
||||
yield* session.prompt({
|
||||
sessionID: child.id,
|
||||
prompt: PromptInput.Prompt.make({ text: "Explore" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-subagent-telemetry", name: "echo", input: { text: "found" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
yield* session.resume(child.id)
|
||||
|
||||
const agent = spans.find((span) => span.name === "invoke_agent")
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(agent?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
|
||||
[ATTR_GEN_AI_AGENT_NAME, "explore"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
|
||||
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(tool?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_AGENT_NAME, "explore"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
|
||||
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(ancestorNames(tool)).toContain("invoke_agent")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("distinguishes input and tool-result model calls", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: PromptInput.Prompt.make({ text: "Use echo twice" }),
|
||||
resume: false,
|
||||
})
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-context-1", name: "echo", input: { text: "first" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-context-2", name: "echo", input: { text: "second" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const tools = spans.filter((span) => span.name === "execute_tool echo")
|
||||
expect(tools).toHaveLength(2)
|
||||
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(1)
|
||||
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("input")
|
||||
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(2)
|
||||
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("tool_result")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks failed tool spans before returning the error to the model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail tool" }), resume: false })
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-telemetry-failure", name: "echo", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(tool?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Failure")
|
||||
requests.length = 0
|
||||
spans.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -1445,6 +1665,12 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(
|
||||
spans
|
||||
.filter((span) => span.name === "invoke_agent")
|
||||
.at(-1)
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_COMPACTION_COMPLETED)?.[2],
|
||||
).toMatchObject({ [ATTR_OPENCODE_COMPACTION_REASON]: "automatic" })
|
||||
expect(userTexts(requests[0])[0]).toContain("## Objective")
|
||||
expect(userTexts(requests[1])).toHaveLength(1)
|
||||
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
|
|
@ -3429,6 +3655,9 @@ describe("SessionRunnerLLM", () => {
|
|||
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
|
||||
const agent = spans.find((span) => span.name === "invoke_agent")
|
||||
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
|
||||
expect(agent?.status._tag === "Ended" && agent.status.exit._tag).toBe("Failure")
|
||||
yield* session.interrupt(sessionID)
|
||||
}),
|
||||
)
|
||||
|
|
@ -3766,7 +3995,20 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests).toHaveLength(2)
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes).toContain("session.retry.scheduled.1")
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name === "invoke_agent")
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
|
||||
).toMatchObject({
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: 2,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_MS]: 2_000,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "backoff",
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
|
||||
[ATTR_ERROR_TYPE]: "provider.transport",
|
||||
})
|
||||
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
|
||||
expect(spans.find((span) => span.name === "invoke_agent")?.attributes.has(ATTR_OPENCODE_ERROR_STAGE)).toBeFalse()
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
|
|
@ -3826,8 +4068,26 @@ describe("SessionRunnerLLM", () => {
|
|||
{ attempt: 4, at: 14_000 },
|
||||
{ attempt: 5, at: 30_000 },
|
||||
])
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name === "invoke_agent")
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
|
||||
).toMatchObject({
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: "exhausted",
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: 5,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
|
||||
})
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
const agent = spans.find((span) => span.name === "invoke_agent")
|
||||
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider.transport")
|
||||
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
|
||||
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("model")
|
||||
if (agent?.status._tag === "Ended" && agent.status.exit._tag === "Failure") {
|
||||
const failure = Cause.squash(agent.status.exit.cause)
|
||||
expect(failure).toMatchObject({ message: "provider.transport" })
|
||||
expect(failure).not.toMatchObject({ message: "Provider unavailable" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -4310,3 +4570,13 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function ancestorNames(span: Tracer.NativeSpan | undefined) {
|
||||
const names: string[] = []
|
||||
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
|
||||
while (current?._tag === "Span") {
|
||||
names.push(current.name)
|
||||
current = current.parent._tag === "Some" ? current.parent.value : undefined
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { DateTime, Effect, Layer, Schema, Tracer } from "effect"
|
||||
import {
|
||||
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
|
||||
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
|
|
@ -22,6 +26,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
|
@ -30,6 +35,7 @@ const childText = "child final response"
|
|||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
let resumedWith: Tracer.AnySpan | undefined
|
||||
|
||||
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
|
||||
|
||||
|
|
@ -76,7 +82,11 @@ const executionNode = makeGlobalNode({
|
|||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
resume: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
resumedWith = (yield* SessionTelemetry.TraceParent) ?? undefined
|
||||
return yield* complete(sessionID)
|
||||
}),
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
|
|
@ -171,6 +181,14 @@ describe("SubagentTool", () => {
|
|||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -181,7 +199,7 @@ describe("SubagentTool", () => {
|
|||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this" },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||
|
|
@ -191,6 +209,10 @@ describe("SubagentTool", () => {
|
|||
agent: "reviewer",
|
||||
model: childModel,
|
||||
})
|
||||
const span = spans.find((span) => span.name === "execute_tool subagent")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_AGENT_NAME)).toBe("reviewer")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_SESSION_ID)).toBe(child.id)
|
||||
expect(resumedWith?.spanId).toBe(span?.spanId)
|
||||
|
||||
const fallback = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -255,6 +277,15 @@ describe("SubagentTool", () => {
|
|||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
resumedWith = undefined
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -265,7 +296,7 @@ describe("SubagentTool", () => {
|
|||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
|
|
@ -274,6 +305,7 @@ describe("SubagentTool", () => {
|
|||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(childText)
|
||||
expect(resumedWith).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { Duration, Effect, Fiber, Layer, Schema, Tracer } from "effect"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -88,9 +95,21 @@ describe("WebFetchTool registration", () => {
|
|||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://example.com/public"
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
expect(
|
||||
yield* settleTool(registry, call({ url, format: "text", timeout: 4 })).pipe(
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "hello" },
|
||||
output: {
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
|
|
@ -101,6 +120,38 @@ describe("WebFetchTool registration", () => {
|
|||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
const tool = spans.find((span) => span.name === "execute_tool webfetch")
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
|
||||
expect(http?.name).toBe("GET")
|
||||
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(80)
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).toBe(url)
|
||||
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(tool?.spanId)
|
||||
expect(requests[0]?.headers.traceparent).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks rejected HTTP statuses on the HTTP span", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
respond = () => Effect.succeed(new Response("missing", { status: 404 }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
yield* settleTool(registry, call({ url: "https://example.com/missing", format: "text" })).pipe(
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
|
||||
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(404)
|
||||
expect(http?.attributes.get(ATTR_ERROR_TYPE)).toBe("StatusCodeError")
|
||||
expect(http?.status._tag === "Ended" && http.status.exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue