Files
simstudioai--sim/apps/sim/lib/copilot/request/otel.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

594 lines
21 KiB
TypeScript

import { randomBytes } from 'crypto'
import {
type Context,
context,
ROOT_CONTEXT,
type Span,
type SpanContext,
SpanKind,
SpanStatusCode,
TraceFlags,
trace,
} from '@opentelemetry/api'
import { describeError, toError } from '@sim/utils/errors'
import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1'
import {
CopilotBranchKind,
CopilotRequestCancelReason,
type CopilotRequestCancelReasonValue,
CopilotSurface,
CopilotTransport,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
import { isExplicitStopReason } from '@/lib/copilot/request/session/abort-reason'
// OTel GenAI content-capture env var (spec:
// https://opentelemetry.io/docs/specs/semconv/gen-ai/). Mirrored on
// the Go side so a single var controls both halves.
const GENAI_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
// OTLP backends commonly reject attrs over 64 KiB; cap proactively.
const GENAI_MESSAGE_ATTR_MAX_BYTES = 60 * 1024
function isGenAIMessageCaptureEnabled(): boolean {
const raw = (process.env[GENAI_CAPTURE_ENV] || '').toLowerCase().trim()
return raw === 'true' || raw === '1' || raw === 'yes'
}
// True iff `err` represents the user explicitly clicking Stop — the
// only cancellation we treat as expected (non-error).
//
// Policy across the codebase: an explicit user stop leaves span
// status UNSET; every other cancellation (client tab close,
// network drop, internal timeout, uncategorized abort) escalates
// to `status=error` so it shows up on error dashboards. This is
// the Sim mirror of `requestctx.IsExplicitUserStop` on the Go
// side; keep the two semantically aligned.
//
// Detection modes:
//
// - Plain-string reject value: `controller.abort('user_stop:...')`
// rejects fetch() with the reason STRING directly. Matches
// `isExplicitStopReason()` exactly (UserStop / RedisPoller).
// - DOMException / Error object: `controller.abort()` with no arg
// (or older runtimes) rejects with an AbortError whose `.cause`
// or `.message` may carry the reason. We inspect both.
//
// Anything that doesn't resolve to an explicit-stop reason (plain
// AbortError with no identifiable cause, timeout-flavored aborts,
// arbitrary Error instances) returns false and gets `status=error`.
export function isExplicitUserStopError(err: unknown): boolean {
if (err == null) return false
if (typeof err === 'string') return isExplicitStopReason(err)
if (typeof err === 'object') {
const e = err as { cause?: unknown; message?: unknown }
if (isExplicitStopReason(e.cause)) return true
if (typeof e.message === 'string' && isExplicitStopReason(e.message)) return true
}
return false
}
/**
* True iff an HTTP response status code represents a real server-side
* problem (5xx) or a user-visible condition we want to alert on
* (402 Payment Required, 409 Conflict, 429 Too Many Requests).
*
* Everything else — in particular the 4xx flood from bot probes and
* expected auth/validation rejections — stays UNSET on the span so
* dashboards don't treat normal rejections as errors.
*
* Mirrored on the Go side in
* `copilot/internal/http/middleware/telemetry.go`. Keep the two in
* sync if you change the actionable set.
*/
export function isActionableErrorStatus(code: number): boolean {
if (code >= 500) return true
return code === 402 || code === 409 || code === 429
}
// Record exception + set ERROR unless the error is an explicit user
// stop (see `isExplicitUserStopError`). Every other cancellation —
// client disconnect, internal timeout, uncategorized AbortError —
// becomes a real error that the dashboards will surface.
export function markSpanForError(span: Span, error: unknown): void {
const asError = toError(error)
span.recordException(asError)
const described = describeError(error)
if (described.code) {
span.setAttribute(TraceAttr.ErrorCode, described.code)
}
if (!isExplicitUserStopError(error)) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: described.causeChain ? described.causeChain.join(' <- ') : asError.message,
})
}
}
// OTel GenAI message shape (kept minimal). Mirror changes on the Go side.
interface GenAIAgentPart {
type: 'text' | 'tool_call' | 'tool_call_response'
content?: string
id?: string
name?: string
arguments?: Record<string, unknown>
response?: string
}
interface GenAIAgentMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
parts: GenAIAgentPart[]
}
function marshalAgentMessages(messages: GenAIAgentMessage[]): string | undefined {
if (messages.length === 0) return undefined
const json = JSON.stringify(messages)
if (json.length <= GENAI_MESSAGE_ATTR_MAX_BYTES) return json
// Simple tail-preserving truncation: drop from the front until we
// fit. Matches the Go side's behavior. The last message is
// usually the most diagnostic for span-level outcome.
let remaining = messages.slice()
while (remaining.length > 1) {
remaining = remaining.slice(1)
const candidate = JSON.stringify(remaining)
if (candidate.length <= GENAI_MESSAGE_ATTR_MAX_BYTES) return candidate
}
// Single message still over cap — truncate the text part in place
// with a marker so the partial content is still readable.
const only = remaining[0]
for (const part of only.parts) {
if (part.type === 'text' && part.content) {
const headroom = GENAI_MESSAGE_ATTR_MAX_BYTES - 1024
if (part.content.length > headroom) {
part.content = `${part.content.slice(0, headroom)}\n\n[truncated: capture cap ${GENAI_MESSAGE_ATTR_MAX_BYTES} bytes]`
}
}
}
const final = JSON.stringify([only])
return final.length <= GENAI_MESSAGE_ATTR_MAX_BYTES ? final : undefined
}
interface CopilotAgentInputMessages {
userMessage?: string
systemPrompt?: string
}
interface CopilotAgentOutputMessages {
assistantText?: string
toolCalls?: Array<{
id: string
name: string
arguments?: Record<string, unknown>
}>
}
function setAgentInputMessages(span: Span, input: CopilotAgentInputMessages): void {
if (!isGenAIMessageCaptureEnabled()) return
const messages: GenAIAgentMessage[] = []
if (input.systemPrompt) {
messages.push({
role: 'system',
parts: [{ type: 'text', content: input.systemPrompt }],
})
}
if (input.userMessage) {
messages.push({
role: 'user',
parts: [{ type: 'text', content: input.userMessage }],
})
}
const serialized = marshalAgentMessages(messages)
if (serialized) {
span.setAttribute(TraceAttr.GenAiInputMessages, serialized)
}
}
function setAgentOutputMessages(span: Span, output: CopilotAgentOutputMessages): void {
if (!isGenAIMessageCaptureEnabled()) return
const parts: GenAIAgentPart[] = []
if (output.assistantText) {
parts.push({ type: 'text', content: output.assistantText })
}
for (const tc of output.toolCalls ?? []) {
parts.push({
type: 'tool_call',
id: tc.id,
name: tc.name,
...(tc.arguments ? { arguments: tc.arguments } : {}),
})
}
if (parts.length === 0) return
const serialized = marshalAgentMessages([{ role: 'assistant', parts }])
if (serialized) {
span.setAttribute(TraceAttr.GenAiOutputMessages, serialized)
}
}
export type CopilotLifecycleOutcome =
(typeof RequestTraceV1Outcome)[keyof typeof RequestTraceV1Outcome]
// Lazy tracer — Next 16/Turbopack can evaluate modules before NodeSDK
// installs the real TracerProvider; resolving per call avoids a
// cached NoOpTracer silently disabling OTel.
export function getCopilotTracer() {
return trace.getTracer('sim-ai-platform', '1.0.0')
}
function getTracer() {
return getCopilotTracer()
}
// Wrap an inbound handler that Go called into so its span parents
// under the Go-side trace (via `traceparent`).
export async function withIncomingGoSpan<T>(
headers: Headers,
spanName: string,
attributes: Record<string, string | number | boolean> | undefined,
fn: (span: Span) => Promise<T>
): Promise<T> {
const parentContext = contextFromRequestHeaders(headers)
const tracer = getTracer()
return tracer.startActiveSpan(
spanName,
{ kind: SpanKind.SERVER, attributes },
parentContext,
async (span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
)
}
// Wrap a copilot-lifecycle op in an OTel span. Pass `parentContext`
// explicitly when AsyncLocalStorage-tracked context can be dropped
// across multiple awaits (otherwise the child falls back to a framework
// span that the sampler drops).
export async function withCopilotSpan<T>(
spanName: string,
attributes: Record<string, string | number | boolean> | undefined,
fn: (span: Span) => Promise<T>,
parentContext?: Context
): Promise<T> {
const tracer = getTracer()
const runBody = async (span: Span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
if (parentContext) {
return tracer.startActiveSpan(spanName, { attributes }, parentContext, runBody)
}
return tracer.startActiveSpan(spanName, { attributes }, runBody)
}
// External OTel `tool.execute` span for Sim-side tool work (the Go
// side's `tool.execute` is just the enqueue, stays ~0ms).
export async function withCopilotToolSpan<T>(
input: {
toolName: string
toolCallId: string
runId?: string
chatId?: string
argsBytes?: number
argsPreview?: string
},
fn: (span: Span) => Promise<T>
): Promise<T> {
const tracer = getTracer()
return tracer.startActiveSpan(
`tool.execute ${input.toolName}`,
{
attributes: {
[TraceAttr.ToolName]: input.toolName,
[TraceAttr.ToolCallId]: input.toolCallId,
[TraceAttr.ToolExecutor]: 'sim',
...(input.runId ? { [TraceAttr.RunId]: input.runId } : {}),
...(input.chatId ? { [TraceAttr.ChatId]: input.chatId } : {}),
...(typeof input.argsBytes === 'number'
? { [TraceAttr.ToolArgsBytes]: input.argsBytes }
: {}),
// argsPreview can leak pasted credentials in tool args; gate
// behind the GenAI content-capture env var.
...(input.argsPreview && isGenAIMessageCaptureEnabled()
? { [TraceAttr.ToolArgsPreview]: input.argsPreview }
: {}),
},
},
async (span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
)
}
function isValidSpanContext(spanContext: SpanContext): boolean {
return (
/^[0-9a-f]{32}$/.test(spanContext.traceId) &&
spanContext.traceId !== '00000000000000000000000000000000' &&
/^[0-9a-f]{16}$/.test(spanContext.spanId) &&
spanContext.spanId !== '0000000000000000'
)
}
function createFallbackSpanContext(): SpanContext {
return {
traceId: randomBytes(16).toString('hex'),
spanId: randomBytes(8).toString('hex'),
traceFlags: TraceFlags.SAMPLED,
}
}
interface CopilotOtelScope {
// Leave unset on the chat POST — startCopilotOtelRoot will derive
// from the root span's OTel trace ID (same value Grafana uses).
// Set explicitly on paths that need a non-trace-derived ID (headless,
// resume taking an ID from persisted state).
requestId?: string
route?: string
chatId?: string
workflowId?: string
executionId?: string
runId?: string
streamId?: string
transport: 'headless' | 'stream'
userMessagePreview?: string
}
// Dashboard-column width; long enough for triage disambiguation.
const USER_MESSAGE_PREVIEW_MAX_CHARS = 500
function buildAgentSpanAttributes(
scope: CopilotOtelScope & { requestId: string }
): Record<string, string | number | boolean> {
// Gated behind the same env var as full GenAI message capture — a
// 500-char preview is still user prompt content.
const preview = isGenAIMessageCaptureEnabled()
? truncateUserMessagePreview(scope.userMessagePreview)
: undefined
return {
[TraceAttr.GenAiAgentName]: 'mothership',
[TraceAttr.GenAiAgentId]:
scope.transport === CopilotTransport.Stream ? 'mothership-stream' : 'mothership-headless',
[TraceAttr.GenAiOperationName]:
scope.transport === CopilotTransport.Stream ? 'chat' : 'invoke_agent',
[TraceAttr.RequestId]: scope.requestId,
[TraceAttr.SimRequestId]: scope.requestId,
[TraceAttr.CopilotRoute]: scope.route ?? '',
[TraceAttr.CopilotTransport]: scope.transport,
...(scope.chatId ? { [TraceAttr.ChatId]: scope.chatId } : {}),
...(scope.workflowId ? { [TraceAttr.WorkflowId]: scope.workflowId } : {}),
...(scope.executionId ? { [TraceAttr.CopilotExecutionId]: scope.executionId } : {}),
...(scope.runId ? { [TraceAttr.RunId]: scope.runId } : {}),
...(scope.streamId ? { [TraceAttr.StreamId]: scope.streamId } : {}),
...(preview ? { [TraceAttr.CopilotUserMessagePreview]: preview } : {}),
}
}
function truncateUserMessagePreview(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined
const collapsed = raw.replace(/\s+/g, ' ').trim()
if (!collapsed) return undefined
if (collapsed.length <= USER_MESSAGE_PREVIEW_MAX_CHARS) return collapsed
return `${collapsed.slice(0, USER_MESSAGE_PREVIEW_MAX_CHARS - 1)}…`
}
// Request-shape metadata known only after branch resolution. Stamped
// on the root span for dashboard filtering.
interface CopilotOtelRequestShape {
branchKind?: 'workflow' | 'workspace'
mode?: string
model?: string
provider?: string
createNewChat?: boolean
prefetch?: boolean
fileAttachmentsCount?: number
resourceAttachmentsCount?: number
contextsCount?: number
commandsCount?: number
pendingStreamWaitMs?: number
interruptedPriorStream?: boolean
}
interface CopilotOtelRoot {
span: Span
context: Context
/**
* Finalize the root span. `cancelReason`, when provided, decides
* whether a `cancelled` outcome leaves span status UNSET (for
* explicit user stops — our single non-error cancel class) or
* escalates to ERROR (client disconnect, unknown, etc.). Omit it
* for non-cancellation outcomes.
*/
finish: (
outcome?: CopilotLifecycleOutcome,
error?: unknown,
cancelReason?: CopilotRequestCancelReasonValue
) => void
setInputMessages: (input: CopilotAgentInputMessages) => void
setOutputMessages: (output: CopilotAgentOutputMessages) => void
setRequestShape: (shape: CopilotOtelRequestShape) => void
}
export function startCopilotOtelRoot(
scope: CopilotOtelScope
): CopilotOtelRoot & { requestId: string } {
// TRUE root — don't inherit from Next's HTTP handler span (the
// sampler drops those; we'd orphan the whole mothership tree).
const parentContext = ROOT_CONTEXT
// Start with a placeholder `requestId`, then overwrite using the
// span's actual trace ID so the UI copy-button value pastes
// directly into Grafana.
const span = getTracer().startSpan(
TraceSpan.GenAiAgentExecute,
{ attributes: buildAgentSpanAttributes({ ...scope, requestId: '' }) },
parentContext
)
const carrierSpan = isValidSpanContext(span.spanContext())
? span
: trace.wrapSpanContext(createFallbackSpanContext())
const spanContext = carrierSpan.spanContext()
const requestId =
scope.requestId ??
(spanContext.traceId && spanContext.traceId.length === 32 ? spanContext.traceId : '')
span.setAttribute(TraceAttr.RequestId, requestId)
span.setAttribute(TraceAttr.SimRequestId, requestId)
const rootContext = trace.setSpan(parentContext, carrierSpan)
let finished = false
const finish: CopilotOtelRoot['finish'] = (outcome, error, cancelReason) => {
if (finished) return
finished = true
const resolvedOutcome = outcome ?? RequestTraceV1Outcome.success
span.setAttribute(TraceAttr.CopilotRequestOutcome, resolvedOutcome)
// Policy: `explicit_stop` is the ONLY cancellation we treat as
// expected (status unset → dashboards see it as OK). Everything
// else — client_disconnect, unknown reason, bug-case cancels —
// escalates to ERROR so it shows up on error panels.
const isExplicitStop = cancelReason === CopilotRequestCancelReason.ExplicitStop
if (error) {
markSpanForError(span, error)
if (isExplicitStop || isExplicitUserStopError(error)) {
span.setStatus({ code: SpanStatusCode.OK })
}
} else if (resolvedOutcome === RequestTraceV1Outcome.success) {
span.setStatus({ code: SpanStatusCode.OK })
} else if (resolvedOutcome === RequestTraceV1Outcome.cancelled) {
if (isExplicitStop) {
span.setStatus({ code: SpanStatusCode.OK })
} else {
span.setStatus({
code: SpanStatusCode.ERROR,
message: `cancelled: ${cancelReason ?? 'unknown'}`,
})
}
}
span.end()
}
return {
span,
context: rootContext,
requestId,
finish,
setInputMessages: (input) => setAgentInputMessages(span, input),
setOutputMessages: (output) => setAgentOutputMessages(span, output),
setRequestShape: (shape) => applyRequestShape(span, shape),
}
}
// Pending-stream-lock wait above this = inferred send-to-interrupt.
const INTERRUPT_WAIT_MS_THRESHOLD = 50
function applyRequestShape(span: Span, shape: CopilotOtelRequestShape): void {
if (shape.branchKind) {
span.setAttribute(TraceAttr.CopilotBranchKind, shape.branchKind)
span.setAttribute(
TraceAttr.CopilotSurface,
shape.branchKind === CopilotBranchKind.Workflow
? CopilotSurface.Copilot
: CopilotSurface.Mothership
)
}
if (shape.mode) span.setAttribute(TraceAttr.CopilotMode, shape.mode)
if (shape.model) span.setAttribute(TraceAttr.GenAiRequestModel, shape.model)
if (shape.provider) span.setAttribute(TraceAttr.GenAiSystem, shape.provider)
if (typeof shape.createNewChat === 'boolean') {
span.setAttribute(TraceAttr.CopilotChatIsNew, shape.createNewChat)
}
if (typeof shape.prefetch === 'boolean') {
span.setAttribute(TraceAttr.CopilotPrefetch, shape.prefetch)
}
if (typeof shape.fileAttachmentsCount === 'number') {
span.setAttribute(TraceAttr.CopilotFileAttachmentsCount, shape.fileAttachmentsCount)
}
if (typeof shape.resourceAttachmentsCount === 'number') {
span.setAttribute(TraceAttr.CopilotResourceAttachmentsCount, shape.resourceAttachmentsCount)
}
if (typeof shape.contextsCount === 'number') {
span.setAttribute(TraceAttr.CopilotContextsCount, shape.contextsCount)
}
if (typeof shape.commandsCount === 'number') {
span.setAttribute(TraceAttr.CopilotCommandsCount, shape.commandsCount)
}
if (typeof shape.pendingStreamWaitMs === 'number') {
span.setAttribute(TraceAttr.CopilotPendingStreamWaitMs, shape.pendingStreamWaitMs)
const interrupted =
typeof shape.interruptedPriorStream === 'boolean'
? shape.interruptedPriorStream
: shape.pendingStreamWaitMs > INTERRUPT_WAIT_MS_THRESHOLD
span.setAttribute(TraceAttr.CopilotInterruptedPriorStream, interrupted)
} else if (typeof shape.interruptedPriorStream === 'boolean') {
span.setAttribute(TraceAttr.CopilotInterruptedPriorStream, shape.interruptedPriorStream)
}
}
export async function withCopilotOtelContext<T>(
scope: CopilotOtelScope,
fn: (otelContext: Context) => Promise<T>
): Promise<T> {
const parentContext = context.active()
// Same trace-id-derives-requestId dance as startCopilotOtelRoot — see
// that function for the rationale. Stamp a placeholder, read the real
// trace ID off the span, then overwrite.
const span = getTracer().startSpan(
TraceSpan.GenAiAgentExecute,
{ attributes: buildAgentSpanAttributes({ ...scope, requestId: scope.requestId ?? '' }) },
parentContext
)
const carrierSpan = isValidSpanContext(span.spanContext())
? span
: trace.wrapSpanContext(createFallbackSpanContext())
const spanContext = carrierSpan.spanContext()
const resolvedRequestId =
scope.requestId ??
(spanContext.traceId && spanContext.traceId.length === 32 ? spanContext.traceId : '')
if (resolvedRequestId) {
span.setAttribute(TraceAttr.RequestId, resolvedRequestId)
span.setAttribute(TraceAttr.SimRequestId, resolvedRequestId)
}
const otelContext = trace.setSpan(parentContext, carrierSpan)
let terminalStatusSet = false
try {
const result = await context.with(otelContext, () => fn(otelContext))
span.setStatus({ code: SpanStatusCode.OK })
terminalStatusSet = true
return result
} catch (error) {
markSpanForError(span, error)
terminalStatusSet = true
throw error
} finally {
if (!terminalStatusSet) {
// Extremely defensive: should be unreachable, but avoids leaking
// an unset span status if some future refactor breaks both arms.
span.setStatus({ code: SpanStatusCode.OK })
}
span.end()
}
}