Skip to content

Commit 19c36ac

Browse files
committed
feat(copilot): server-side mothership tool/vfs/file metrics + $env tagging
- copilot.tool.duration/calls, vfs.materialize, file.read metrics on Sim's MeterProvider (shared buckets, bounded labels, name-capped) at the tool/VFS/file-read boundaries - metrics-v1 TS mirror + sync-metrics-contract script + GENERATORS entry; regen trace-attributes/events mirrors (adds gen_ai.system) - deployment.environment from APPCONFIG_ENVIRONMENT (production -> prod) so a single Grafana $env spans Sim + Go without a new infra env var
1 parent d89824c commit 19c36ac

11 files changed

Lines changed: 390 additions & 16 deletions

File tree

apps/sim/instrumentation-node.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ function normalizeOtlpMetricsUrl(url: string): string {
8383
}
8484
}
8585

86+
// deployment.environment in the GO value space (dev | staging | prod) without
87+
// any new infra env var. Every deployed Sim tier already gets
88+
// APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we
89+
// reuse it and map production -> prod to match Go's `OTEL_DEPLOYMENT_ENVIRONMENT`
90+
// (and thus a single Grafana $env filter spans Sim + Go). Returns undefined when
91+
// unset (local dev) so the OTEL_/NODE_ENV fallbacks still apply.
92+
function deploymentEnvFromAppConfig(v: string | undefined): string | undefined {
93+
if (!v) return undefined
94+
return v === 'production' ? 'prod' : v
95+
}
96+
8697
// Sampling ratio from env (mirrors Go's `samplerFromEnv`); fallback
8798
// is 100% everywhere. Retention caps cost, not sampling.
8899
function resolveSamplingRatio(_isLocalEndpoint: boolean): number {
@@ -270,11 +281,15 @@ async function initializeOpenTelemetry() {
270281
resourceFromAttributes({
271282
[ATTR_SERVICE_NAME]: telemetryConfig.serviceName,
272283
[ATTR_SERVICE_VERSION]: telemetryConfig.serviceVersion,
273-
// OTEL_ → DEPLOYMENT_ENVIRONMENT → NODE_ENV; matches Go's
274-
// `resourceEnvFromEnv()` so both halves tag the same value.
284+
// OTEL_ → DEPLOYMENT_ENVIRONMENT → APPCONFIG_ENVIRONMENT (mapped to the
285+
// Go value space) → NODE_ENV. Matches Go's `resourceEnvFromEnv()` so a
286+
// single $env spans Sim + Go. APPCONFIG_ENVIRONMENT (already set on every
287+
// deployed tier) is the fix that stops deployed Sim tagging everything
288+
// "production" via the NODE_ENV fallback — no new infra env var needed.
275289
[ATTR_DEPLOYMENT_ENVIRONMENT]:
276290
process.env.OTEL_DEPLOYMENT_ENVIRONMENT ||
277291
process.env.DEPLOYMENT_ENVIRONMENT ||
292+
deploymentEnvFromAppConfig(process.env.APPCONFIG_ENVIRONMENT) ||
278293
env.NODE_ENV ||
279294
'development',
280295
'service.namespace': 'mothership',
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// AUTO-GENERATED FILE. DO NOT EDIT.
2+
//
3+
// Source: copilot/copilot/contracts/metrics-v1.schema.json
4+
// Regenerate with: bun run metrics-contract:generate
5+
//
6+
// Canonical mothership OTel metric names. Call sites should reference
7+
// `Metric.<Identifier>` (e.g. `Metric.CopilotToolDuration`) rather than raw
8+
// string literals, so the Go-side contract is the single source of truth and
9+
// typos become compile errors.
10+
//
11+
// NAMES ONLY. Label keys and histogram bucket boundaries are NOT in this
12+
// contract — Go owns the label-cardinality allowlist and the shared bucket
13+
// constant, and the Sim emitter MUST mirror those by hand so the Go∪Sim metric
14+
// union is queryable as one series set.
15+
16+
export const Metric = {
17+
CopilotCacheAttempted: 'copilot.cache.attempted',
18+
CopilotCacheHit: 'copilot.cache.hit',
19+
CopilotCacheWrite: 'copilot.cache.write',
20+
CopilotFileReadDuration: 'copilot.file.read.duration',
21+
CopilotFileReadSize: 'copilot.file.read.size',
22+
CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration',
23+
CopilotRequestCount: 'copilot.request.count',
24+
CopilotRequestDuration: 'copilot.request.duration',
25+
CopilotToolCalls: 'copilot.tool.calls',
26+
CopilotToolDuration: 'copilot.tool.duration',
27+
CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration',
28+
GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage',
29+
GenAiClientTokenUsage: 'gen_ai.client.token.usage',
30+
LlmClientErrors: 'llm.client.errors',
31+
LlmClientOutputCutoff: 'llm.client.output_cutoff',
32+
LlmClientStreamDuration: 'llm.client.stream.duration',
33+
LlmClientTimeToFirstToken: 'llm.client.time_to_first_token',
34+
} as const
35+
36+
export type MetricKey = keyof typeof Metric
37+
export type MetricValue = (typeof Metric)[MetricKey]
38+
39+
/** Readonly sorted list of every canonical mothership metric name. */
40+
export const MetricValues: readonly MetricValue[] = [
41+
'copilot.cache.attempted',
42+
'copilot.cache.hit',
43+
'copilot.cache.write',
44+
'copilot.file.read.duration',
45+
'copilot.file.read.size',
46+
'copilot.messages.serialize.duration',
47+
'copilot.request.count',
48+
'copilot.request.duration',
49+
'copilot.tool.calls',
50+
'copilot.tool.duration',
51+
'copilot.vfs.materialize.duration',
52+
'gen_ai.client.cache.token.usage',
53+
'gen_ai.client.token.usage',
54+
'llm.client.errors',
55+
'llm.client.output_cutoff',
56+
'llm.client.stream.duration',
57+
'llm.client.time_to_first_token',
58+
] as const

apps/sim/lib/copilot/generated/trace-attributes-v1.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,8 @@ export const TraceAttr = {
5454
AuthProvider: 'auth.provider',
5555
AuthValidateStatusCode: 'auth.validate.status_code',
5656
AwsRegion: 'aws.region',
57-
BedrockErrorCode: 'bedrock.error_code',
58-
BedrockModelId: 'bedrock.model_id',
59-
BedrockRequestBodyBytesRetry: 'bedrock.request.body_bytes_retry',
6057
BillingAttempts: 'billing.attempts',
58+
BillingByok: 'billing.byok',
6159
BillingChangeType: 'billing.change_type',
6260
BillingCostInputUsd: 'billing.cost.input_usd',
6361
BillingCostOutputUsd: 'billing.cost.output_usd',
@@ -159,6 +157,14 @@ export const TraceAttr = {
159157
ContextReduced: 'context.reduced',
160158
ContextSummarizeInputChars: 'context.summarize.input_chars',
161159
ContextSummarizeOutputChars: 'context.summarize.output_chars',
160+
ContextTransformCaller: 'context.transform.caller',
161+
ContextTransformCharsIn: 'context.transform.chars_in',
162+
ContextTransformCharsOut: 'context.transform.chars_out',
163+
ContextTransformDropCount: 'context.transform.drop_count',
164+
ContextTransformDrops: 'context.transform.drops',
165+
ContextTransformMessagesIn: 'context.transform.messages_in',
166+
ContextTransformMessagesOut: 'context.transform.messages_out',
167+
ContextTransformStage: 'context.transform.stage',
162168
CopilotAbortControllerFired: 'copilot.abort.controller_fired',
163169
CopilotAbortGoMarkerOk: 'copilot.abort.go_marker_ok',
164170
CopilotAbortLocalAborted: 'copilot.abort.local_aborted',
@@ -276,6 +282,7 @@ export const TraceAttr = {
276282
CopilotVfsOutcome: 'copilot.vfs.outcome',
277283
CopilotVfsOutputBytes: 'copilot.vfs.output.bytes',
278284
CopilotVfsOutputMediaType: 'copilot.vfs.output.media_type',
285+
CopilotVfsPhase: 'copilot.vfs.phase',
279286
CopilotVfsReadImageResized: 'copilot.vfs.read.image.resized',
280287
CopilotVfsReadOutcome: 'copilot.vfs.read.outcome',
281288
CopilotVfsReadOutputBytes: 'copilot.vfs.read.output.bytes',
@@ -389,6 +396,7 @@ export const TraceAttr = {
389396
GenAiRequestToolUseBlocks: 'gen_ai.request.tool_use_blocks',
390397
GenAiRequestToolsCount: 'gen_ai.request.tools.count',
391398
GenAiRequestUserMessages: 'gen_ai.request.user_messages',
399+
GenAiResponseFinishReasons: 'gen_ai.response.finish_reasons',
392400
GenAiResponseModel: 'gen_ai.response.model',
393401
GenAiStreamPhaseTextBytes: 'gen_ai.stream.phase.text.bytes',
394402
GenAiStreamPhaseTextChunks: 'gen_ai.stream.phase.text.chunks',
@@ -434,7 +442,9 @@ export const TraceAttr = {
434442
InvitationRole: 'invitation.role',
435443
KnowledgeBaseId: 'knowledge_base.id',
436444
KnowledgeBaseName: 'knowledge_base.name',
445+
LlmBackend: 'llm.backend',
437446
LlmErrorStage: 'llm.error_stage',
447+
LlmProtocol: 'llm.protocol',
438448
LlmRequestBodyBytes: 'llm.request.body_bytes',
439449
LlmStreamBytes: 'llm.stream.bytes',
440450
LlmStreamChunks: 'llm.stream.chunks',
@@ -460,6 +470,10 @@ export const TraceAttr = {
460470
MemoryPath: 'memory.path',
461471
MemoryRowCount: 'memory.row_count',
462472
MessageId: 'message.id',
473+
MessagesDeserializeMs: 'messages.deserialize_ms',
474+
MessagesSerializeOp: 'messages.serialize.op',
475+
MessagesSerializeSite: 'messages.serialize.site',
476+
MessagesSerializeMs: 'messages.serialize_ms',
463477
MessagingDestinationName: 'messaging.destination.name',
464478
MessagingSystem: 'messaging.system',
465479
ModelDurationMs: 'model.duration_ms',
@@ -495,14 +509,15 @@ export const TraceAttr = {
495509
ResumeResultsFailureCount: 'resume.results.failure_count',
496510
ResumeResultsSuccessCount: 'resume.results.success_count',
497511
RouterBackendName: 'router.backend_name',
498-
RouterBedrockEnabled: 'router.bedrock_enabled',
499-
RouterBedrockSupportedModel: 'router.bedrock_supported_model',
512+
RouterConfigVersion: 'router.config_version',
500513
RouterId: 'router.id',
501514
RouterName: 'router.name',
515+
RouterRouteReason: 'router.route_reason',
502516
RouterSelectedBackend: 'router.selected_backend',
503517
RouterSelectedPath: 'router.selected_path',
504518
RunId: 'run.id',
505519
SearchResultsCount: 'search.results_count',
520+
ServerAddress: 'server.address',
506521
ServiceInstanceId: 'service.instance.id',
507522
ServiceName: 'service.name',
508523
ServiceNamespace: 'service.namespace',
@@ -663,10 +678,8 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
663678
'auth.provider',
664679
'auth.validate.status_code',
665680
'aws.region',
666-
'bedrock.error_code',
667-
'bedrock.model_id',
668-
'bedrock.request.body_bytes_retry',
669681
'billing.attempts',
682+
'billing.byok',
670683
'billing.change_type',
671684
'billing.cost.input_usd',
672685
'billing.cost.output_usd',
@@ -768,6 +781,14 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
768781
'context.reduced',
769782
'context.summarize.input_chars',
770783
'context.summarize.output_chars',
784+
'context.transform.caller',
785+
'context.transform.chars_in',
786+
'context.transform.chars_out',
787+
'context.transform.drop_count',
788+
'context.transform.drops',
789+
'context.transform.messages_in',
790+
'context.transform.messages_out',
791+
'context.transform.stage',
771792
'copilot.abort.controller_fired',
772793
'copilot.abort.go_marker_ok',
773794
'copilot.abort.local_aborted',
@@ -885,6 +906,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
885906
'copilot.vfs.outcome',
886907
'copilot.vfs.output.bytes',
887908
'copilot.vfs.output.media_type',
909+
'copilot.vfs.phase',
888910
'copilot.vfs.read.image.resized',
889911
'copilot.vfs.read.outcome',
890912
'copilot.vfs.read.output.bytes',
@@ -987,6 +1009,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
9871009
'gen_ai.request.tool_use_blocks',
9881010
'gen_ai.request.tools.count',
9891011
'gen_ai.request.user_messages',
1012+
'gen_ai.response.finish_reasons',
9901013
'gen_ai.response.model',
9911014
'gen_ai.stream.phase.text.bytes',
9921015
'gen_ai.stream.phase.text.chunks',
@@ -1032,7 +1055,9 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
10321055
'invitation.role',
10331056
'knowledge_base.id',
10341057
'knowledge_base.name',
1058+
'llm.backend',
10351059
'llm.error_stage',
1060+
'llm.protocol',
10361061
'llm.request.body_bytes',
10371062
'llm.stream.bytes',
10381063
'llm.stream.chunks',
@@ -1058,6 +1083,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
10581083
'memory.path',
10591084
'memory.row_count',
10601085
'message.id',
1086+
'messages.deserialize_ms',
1087+
'messages.serialize.op',
1088+
'messages.serialize.site',
1089+
'messages.serialize_ms',
10611090
'messaging.destination.name',
10621091
'messaging.system',
10631092
'model.duration_ms',
@@ -1093,14 +1122,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
10931122
'resume.results.failure_count',
10941123
'resume.results.success_count',
10951124
'router.backend_name',
1096-
'router.bedrock_enabled',
1097-
'router.bedrock_supported_model',
1125+
'router.config_version',
10981126
'router.id',
10991127
'router.name',
1128+
'router.route_reason',
11001129
'router.selected_backend',
11011130
'router.selected_path',
11021131
'run.id',
11031132
'search.results_count',
1133+
'server.address',
11041134
'service.instance.id',
11051135
'service.name',
11061136
'service.namespace',

apps/sim/lib/copilot/generated/trace-events-v1.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// become compile errors.
1111

1212
export const TraceEvent = {
13-
BedrockInvokeRetryWithoutImages: 'bedrock.invoke.retry_without_images',
13+
ContextTransform: 'context.transform',
1414
CopilotOutputFileError: 'copilot.output_file.error',
1515
CopilotSseFirstEvent: 'copilot.sse.first_event',
1616
CopilotSseIdleGapExceeded: 'copilot.sse.idle_gap_exceeded',
@@ -33,7 +33,7 @@ export type TraceEventValue = (typeof TraceEvent)[TraceEventKey]
3333

3434
/** Readonly sorted list of every canonical event name. */
3535
export const TraceEventValues: readonly TraceEventValue[] = [
36-
'bedrock.invoke.retry_without_images',
36+
'context.transform',
3737
'copilot.output_file.error',
3838
'copilot.sse.first_event',
3939
'copilot.sse.idle_gap_exceeded',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Sim server-side copilot metrics (U17). Sim's MeterProvider is wired in
2+
// instrumentation-node.ts (OTLP → Mimir, 60s) but had no copilot instruments;
3+
// this module is its first consumer. We emit the SAME metric names + label keys
4+
// + histogram bucket boundaries as the Go side (copilot internal/telemetry +
5+
// contracts/metrics_v1.go) so the Go∪Sim union is queryable as one series set
6+
// — e.g. `copilot.tool.duration` split by `tool.executor` (go|client|sim).
7+
//
8+
// Bounded cardinality only: tool.name is capped to the shared tool catalog
9+
// (else "other"); vfs phase / file-read outcome are bounded sets. NEVER a
10+
// user/chat/request id (those explode Prometheus series).
11+
import { type Counter, type Histogram, metrics } from '@opentelemetry/api'
12+
import { Metric } from '@/lib/copilot/generated/metrics-v1'
13+
import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1'
14+
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
15+
16+
// MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs
17+
// exactly — a histogram_quantile(sum by (le) …) over the Go∪Sim union is only
18+
// valid with identical boundaries. If you change one side, change the other.
19+
const LATENCY_BUCKETS_MS = [
20+
50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000, 300000,
21+
]
22+
23+
// File sizes span KB→tens of MB; a bytes-appropriate bucket set (not latency).
24+
const BYTE_BUCKETS = [1024, 8192, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456]
25+
26+
interface CopilotMeterInstruments {
27+
toolDuration: Histogram
28+
toolCalls: Counter
29+
vfsMaterializeDuration: Histogram
30+
fileReadDuration: Histogram
31+
fileReadBytes: Histogram
32+
}
33+
34+
let cached: CopilotMeterInstruments | undefined
35+
36+
// Lazy init: Turbopack/Next can evaluate this module before the NodeSDK
37+
// installs the real MeterProvider, so resolve instruments on first use (a
38+
// no-op meter before then simply drops records — same pattern as getCopilotTracer).
39+
function instruments(): CopilotMeterInstruments {
40+
if (cached) return cached
41+
const meter = metrics.getMeter('sim-copilot')
42+
cached = {
43+
toolDuration: meter.createHistogram(Metric.CopilotToolDuration, {
44+
unit: 'ms',
45+
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
46+
}),
47+
toolCalls: meter.createCounter(Metric.CopilotToolCalls),
48+
vfsMaterializeDuration: meter.createHistogram(Metric.CopilotVfsMaterializeDuration, {
49+
unit: 'ms',
50+
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
51+
}),
52+
fileReadDuration: meter.createHistogram(Metric.CopilotFileReadDuration, {
53+
unit: 'ms',
54+
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
55+
}),
56+
fileReadBytes: meter.createHistogram(Metric.CopilotFileReadSize, {
57+
unit: 'By',
58+
advice: { explicitBucketBoundaries: BYTE_BUCKETS },
59+
}),
60+
}
61+
return cached
62+
}
63+
64+
// Caps tool.name to the shared catalog (matches Go's cappedToolName): a
65+
// catalog tool keeps its name, everything else (user MCP/custom/unknown)
66+
// collapses to "other" so series count stays finite.
67+
function cappedToolName(name: string): string {
68+
return TOOL_CATALOG[name] ? name : 'other'
69+
}
70+
71+
// recordSimToolMetric emits copilot.tool.calls (+1) and copilot.tool.duration
72+
// for one server-side Sim tool dispatch (executor=sim). outcome is the bounded
73+
// tool outcome (success/error/…). Pure telemetry.
74+
export function recordSimToolMetric(name: string, outcome: string, durationMs: number): void {
75+
const { toolDuration, toolCalls } = instruments()
76+
const attrs = {
77+
[TraceAttr.ToolName]: cappedToolName(name),
78+
[TraceAttr.ToolExecutor]: 'sim',
79+
[TraceAttr.ToolOutcome]: outcome,
80+
}
81+
toolCalls.add(1, attrs)
82+
if (durationMs >= 0) toolDuration.record(durationMs, attrs)
83+
}
84+
85+
// recordVfsMaterialize records VFS materialization time. Call once per phase
86+
// with that phase's duration and once with phase="total" for the whole op, so
87+
// the dashboard can show total + per-phase. phase must be a bounded value.
88+
export function recordVfsMaterialize(phase: string, durationMs: number): void {
89+
if (durationMs < 0) return
90+
instruments().vfsMaterializeDuration.record(durationMs, {
91+
[TraceAttr.CopilotVfsPhase]: phase,
92+
})
93+
}
94+
95+
// recordFileRead records server-side file-read duration + size by outcome.
96+
export function recordFileRead(outcome: string, durationMs: number, bytes: number): void {
97+
const { fileReadDuration, fileReadBytes } = instruments()
98+
const attrs = { [TraceAttr.CopilotVfsReadOutcome]: outcome }
99+
if (durationMs >= 0) fileReadDuration.record(durationMs, attrs)
100+
if (bytes >= 0) fileReadBytes.record(bytes, attrs)
101+
}

0 commit comments

Comments
 (0)