melchizedek-agents 0.9.2 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_SETUP.md +2 -2
- package/DOCUMENTATION.md +13 -22
- package/README.md +22 -20
- package/config/agents/examples/ares.yaml +0 -2
- package/config/agents/examples/augustin.yaml +0 -2
- package/config/agents/examples/cartographers.yaml +0 -3
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/image_production.yaml +0 -1
- package/config/agents/examples/librarian.yaml +0 -2
- package/config/agents/examples/patient_advocate.yaml +0 -2
- package/config/agents/examples/scriptorium.yaml +0 -3
- package/config/agents/examples/style_council.yaml +1 -5
- package/config/agents/examples/syndicate.yaml +0 -3
- package/config/agents/examples/tutor.yaml +1 -1
- package/config/agents/syndicateSchema.yaml +4 -2
- package/dist/lib/compile.d.ts +64 -0
- package/dist/lib/compile.js +105 -0
- package/dist/lib/models/claudeLlm.js +25 -1
- package/dist/lib/models/gptLlm.js +5 -2
- package/dist/lib/models/ollamaLlm.d.ts +2 -0
- package/dist/lib/models/ollamaLlm.js +4 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
- package/dist/lib/models/openAiCompatibleLlm.js +14 -5
- package/dist/lib/models/schemaNormalize.d.ts +9 -0
- package/dist/lib/models/schemaNormalize.js +34 -0
- package/dist/lib/observability/embeddings.d.ts +21 -0
- package/dist/lib/observability/embeddings.js +47 -0
- package/dist/lib/observability/lineage.d.ts +35 -0
- package/dist/lib/observability/lineage.js +96 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +186 -18
- package/dist/lib/observability/supabaseSpanExporter.js +377 -80
- package/dist/lib/observability/tracer.d.ts +74 -0
- package/dist/lib/observability/tracer.js +282 -1
- package/dist/scripts/a2a_server.js +57 -72
- package/package.json +8 -4
|
@@ -1,35 +1,297 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/observability/supabaseSpanExporter.ts —
|
|
3
|
-
* that persists model-request telemetry to Supabase.
|
|
2
|
+
* lib/observability/supabaseSpanExporter.ts — the observability ledger sink.
|
|
4
3
|
*
|
|
5
4
|
* WHY this file exists:
|
|
6
5
|
* The console exporter in tracer.ts prints every span as an
|
|
7
|
-
* [OTEL_SPAN_JSON] line — good for a terminal, gone when it scrolls.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* [OTEL_SPAN_JSON] line — good for a terminal, gone when it scrolls. With
|
|
7
|
+
* TELEMETRY_SUPABASE=true (and Supabase credentials) every turn and every
|
|
8
|
+
* model call is also written to Supabase, in three tiers with three
|
|
9
|
+
* lifetimes (schema: db/telemetry.sql, lockdown: db/hardening.sql):
|
|
10
|
+
*
|
|
11
|
+
* adk_turns one row per turn — the system of record: input, output,
|
|
12
|
+
* route, agent, errors, tokens, model-vs-tool time, the
|
|
13
|
+
* tool calls WITH their responses, the ids that join it to
|
|
14
|
+
* the session (session_id, invocation_id) and the task,
|
|
15
|
+
* and provenance (config_hash, engine_version).
|
|
16
|
+
* adk_telemetry one row per span — llm.request and root spans, raw.
|
|
17
|
+
* adk_payloads full request/response per model call, captured by
|
|
18
|
+
* POLICY and expiring. ADK's own call_llm spans carry the
|
|
19
|
+
* assembled prompt and raw response; they are 10-100x a
|
|
20
|
+
* turn row, so: errors and fallbacks always, a
|
|
21
|
+
* deterministic sample of the rest, 30-day TTL.
|
|
12
22
|
*
|
|
13
23
|
* DESIGN NOTES:
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
24
|
+
* - Never on the hot path: inserts are fire-and-forget behind a
|
|
25
|
+
* BatchSpanProcessor; a failure is logged once per table and the rows
|
|
26
|
+
* are spooled to a dead-letter file (OUTPUTS_DIR/telemetry-deadletter.ndjson)
|
|
27
|
+
* so a sink outage loses nothing silently. `npm run telemetry:stats`
|
|
28
|
+
* shows what landed; `telemetry:replay` re-sends the spool.
|
|
29
|
+
* - Payload decisions need the TURN's outcome (error? fallback?), which is
|
|
30
|
+
* only known when the root span ends — after its call_llm children. So
|
|
31
|
+
* call_llm spans wait in a per-trace buffer until their root arrives,
|
|
32
|
+
* then the decision is applied to all of them at once. Children that
|
|
33
|
+
* arrive after the root (a later batch) find the decision cached.
|
|
34
|
+
* - Sampling is deterministic in the trace id: a trace keeps all of its
|
|
35
|
+
* payloads or none, and a re-run of the same policy keeps the same set.
|
|
18
36
|
*/
|
|
37
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
19
39
|
import core from '@opentelemetry/core';
|
|
20
40
|
const { ExportResultCode } = core;
|
|
21
41
|
import { hasSupabaseCredentials } from "../persistence/supabaseProvider.js";
|
|
22
|
-
|
|
42
|
+
import { TELEMETRY_SCHEMA_VERSION } from "./lineage.js";
|
|
43
|
+
/**
|
|
44
|
+
* TELEMETRY_PAYLOADS: off | errors | sample (default) | all
|
|
45
|
+
* TELEMETRY_PAYLOAD_SAMPLE: 0..1, default 0.10
|
|
46
|
+
* TELEMETRY_PAYLOAD_TTL_DAYS: default 30
|
|
47
|
+
*/
|
|
48
|
+
export function payloadPolicyFromEnv(env = process.env) {
|
|
49
|
+
const raw = (env.TELEMETRY_PAYLOADS ?? 'sample').trim().toLowerCase();
|
|
50
|
+
const mode = raw === 'off' || raw === 'errors' || raw === 'all' ? raw : 'sample';
|
|
51
|
+
const rate = Number(env.TELEMETRY_PAYLOAD_SAMPLE ?? '0.10');
|
|
52
|
+
const ttl = Number(env.TELEMETRY_PAYLOAD_TTL_DAYS ?? '30');
|
|
53
|
+
return {
|
|
54
|
+
mode,
|
|
55
|
+
sampleRate: Number.isFinite(rate) ? Math.min(1, Math.max(0, rate)) : 0.1,
|
|
56
|
+
ttlDays: Number.isFinite(ttl) && ttl > 0 ? ttl : 30,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Deterministic 0..1 value from a trace id (first 8 hex chars). */
|
|
60
|
+
export function traceSampleValue(traceId) {
|
|
61
|
+
const head = parseInt(traceId.slice(0, 8), 16);
|
|
62
|
+
return Number.isFinite(head) ? head / 0x100000000 : 1;
|
|
63
|
+
}
|
|
64
|
+
/** Which payloads of a turn to keep, given the policy and the turn's outcome. */
|
|
65
|
+
export function payloadDecision(policy, traceId, facts) {
|
|
66
|
+
if (policy.mode === 'off')
|
|
67
|
+
return null;
|
|
68
|
+
if (facts.error)
|
|
69
|
+
return 'error';
|
|
70
|
+
if (facts.fallback)
|
|
71
|
+
return 'fallback';
|
|
72
|
+
if (policy.mode === 'errors')
|
|
73
|
+
return null;
|
|
74
|
+
if (policy.mode === 'all')
|
|
75
|
+
return 'all';
|
|
76
|
+
return traceSampleValue(traceId) < policy.sampleRate ? 'sample' : null;
|
|
77
|
+
}
|
|
78
|
+
// ── Span → row mapping (pure; tested offline) ───────────────────────────────
|
|
79
|
+
const ROOT_PREFIX = 'Syndicate Execution: ';
|
|
80
|
+
const EXPORTED_SPAN_NAMES = /^(llm\.request$|Syndicate Execution: )/;
|
|
81
|
+
const ADK_SCOPE = 'gcp.vertex.agent';
|
|
82
|
+
function attrsOf(span) {
|
|
83
|
+
return span.attributes;
|
|
84
|
+
}
|
|
85
|
+
function num(attrs, key) {
|
|
86
|
+
return typeof attrs[key] === 'number' ? attrs[key] : null;
|
|
87
|
+
}
|
|
88
|
+
function str(attrs, key) {
|
|
89
|
+
return typeof attrs[key] === 'string' && attrs[key] !== '' ? attrs[key] : null;
|
|
90
|
+
}
|
|
91
|
+
function bool(attrs, key) {
|
|
92
|
+
return typeof attrs[key] === 'boolean' ? attrs[key] : null;
|
|
93
|
+
}
|
|
94
|
+
function spanTs(span) {
|
|
95
|
+
return new Date(span.startTime[0] * 1000 + span.startTime[1] / 1e6).toISOString();
|
|
96
|
+
}
|
|
97
|
+
function durationMs(span) {
|
|
98
|
+
return span.duration[0] * 1000 + span.duration[1] / 1e6;
|
|
99
|
+
}
|
|
100
|
+
function scopeName(span) {
|
|
101
|
+
const s = span;
|
|
102
|
+
return s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? '';
|
|
103
|
+
}
|
|
104
|
+
export function isRootSpan(span) {
|
|
105
|
+
return span.name.startsWith(ROOT_PREFIX);
|
|
106
|
+
}
|
|
107
|
+
export function isPayloadSpan(span) {
|
|
108
|
+
return span.name === 'call_llm' && scopeName(span) === ADK_SCOPE;
|
|
109
|
+
}
|
|
110
|
+
/** `syndicate.input` is JSON.stringify of either a string or A2A parts. */
|
|
111
|
+
export function parseInputText(raw) {
|
|
112
|
+
if (raw === null || raw === undefined)
|
|
113
|
+
return '';
|
|
114
|
+
if (typeof raw !== 'string')
|
|
115
|
+
return String(raw);
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(raw);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(data)) {
|
|
124
|
+
return data
|
|
125
|
+
.map((p) => (p && typeof p === 'object' ? String(p.text ?? '') : String(p)))
|
|
126
|
+
.join('\n');
|
|
127
|
+
}
|
|
128
|
+
return typeof data === 'string' ? data : JSON.stringify(data);
|
|
129
|
+
}
|
|
130
|
+
function parseLoose(value) {
|
|
131
|
+
if (typeof value !== 'string')
|
|
132
|
+
return value ?? null;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(value);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function syndicateOf(span) {
|
|
141
|
+
const attrs = attrsOf(span);
|
|
142
|
+
return str(attrs, 'syndicate.name') ?? (isRootSpan(span) ? span.name.slice(ROOT_PREFIX.length) : null);
|
|
143
|
+
}
|
|
144
|
+
function spanJson(span) {
|
|
145
|
+
return {
|
|
146
|
+
traceId: span.spanContext().traceId,
|
|
147
|
+
spanId: span.spanContext().spanId,
|
|
148
|
+
name: span.name,
|
|
149
|
+
durationMs: durationMs(span),
|
|
150
|
+
attributes: attrsOf(span),
|
|
151
|
+
events: span.events.map((e) => ({ name: e.name, attributes: e.attributes ?? {} })),
|
|
152
|
+
status: span.status,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Flatten an llm.request or root span into one adk_telemetry row. */
|
|
156
|
+
export function toTelemetryRow(span) {
|
|
157
|
+
const attrs = attrsOf(span);
|
|
158
|
+
return {
|
|
159
|
+
ts: spanTs(span),
|
|
160
|
+
trace_id: span.spanContext().traceId,
|
|
161
|
+
span_id: span.spanContext().spanId,
|
|
162
|
+
span_name: span.name,
|
|
163
|
+
syndicate: syndicateOf(span),
|
|
164
|
+
agent: str(attrs, 'llm.agent') ?? str(attrs, 'syndicate.agent'),
|
|
165
|
+
provider: str(attrs, 'llm.provider'),
|
|
166
|
+
model: str(attrs, 'llm.model'),
|
|
167
|
+
input_tokens: num(attrs, 'llm.tokens.input') ?? num(attrs, 'syndicate.tokens.input'),
|
|
168
|
+
output_tokens: num(attrs, 'llm.tokens.output') ?? num(attrs, 'syndicate.tokens.output'),
|
|
169
|
+
thinking_tokens: num(attrs, 'llm.tokens.thinking') ?? num(attrs, 'syndicate.tokens.thinking'),
|
|
170
|
+
latency_ms: durationMs(span),
|
|
171
|
+
session_id: str(attrs, 'session.id'),
|
|
172
|
+
user_id: str(attrs, 'user.id'),
|
|
173
|
+
task_id: str(attrs, 'a2a.task_id'),
|
|
174
|
+
invocation_id: str(attrs, 'adk.invocation_id'),
|
|
175
|
+
schema_version: TELEMETRY_SCHEMA_VERSION,
|
|
176
|
+
span: spanJson(span),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Project a root span into the adk_turns system-of-record row. */
|
|
180
|
+
export function toTurnRow(span) {
|
|
181
|
+
const attrs = attrsOf(span);
|
|
182
|
+
const toolEvents = span.events
|
|
183
|
+
.filter((e) => e.name === 'ToolCall' || e.name === 'ToolResponse')
|
|
184
|
+
.map((e) => {
|
|
185
|
+
const a = (e.attributes ?? {});
|
|
186
|
+
return e.name === 'ToolCall'
|
|
187
|
+
? { name: 'ToolCall', tool: a['tool.name'] ?? null, args: parseLoose(a['tool.args']) }
|
|
188
|
+
: { name: 'ToolResponse', tool: a['tool.name'] ?? null, data: parseLoose(a['tool.data_gathered']) };
|
|
189
|
+
});
|
|
190
|
+
const models = (str(attrs, 'syndicate.models') ?? '').split(',').filter(Boolean);
|
|
191
|
+
const evalTrial = attrs['eval.trial'];
|
|
192
|
+
return {
|
|
193
|
+
ts: spanTs(span),
|
|
194
|
+
trace_id: span.spanContext().traceId,
|
|
195
|
+
span_id: span.spanContext().spanId,
|
|
196
|
+
session_id: str(attrs, 'session.id'),
|
|
197
|
+
user_id: str(attrs, 'user.id'),
|
|
198
|
+
task_id: str(attrs, 'a2a.task_id'),
|
|
199
|
+
invocation_id: str(attrs, 'adk.invocation_id'),
|
|
200
|
+
syndicate: syndicateOf(span) ?? 'unknown',
|
|
201
|
+
stage: str(attrs, 'syndicate.stage') ?? 'delegate',
|
|
202
|
+
agent: str(attrs, 'syndicate.agent'),
|
|
203
|
+
route: str(attrs, 'syndicate.route'),
|
|
204
|
+
route_reason: str(attrs, 'syndicate.route.reason'),
|
|
205
|
+
route_fell_back: bool(attrs, 'syndicate.route.fell_back'),
|
|
206
|
+
route_via_override: bool(attrs, 'syndicate.route.via_override'),
|
|
207
|
+
relay_fallback: bool(attrs, 'syndicate.relay_fallback') ?? false,
|
|
208
|
+
input: parseInputText(attrs['syndicate.input']),
|
|
209
|
+
output: str(attrs, 'syndicate.output') ?? '',
|
|
210
|
+
error_code: str(attrs, 'syndicate.error.code'),
|
|
211
|
+
error_message: str(attrs, 'syndicate.error.message'),
|
|
212
|
+
input_tokens: num(attrs, 'syndicate.tokens.input'),
|
|
213
|
+
output_tokens: num(attrs, 'syndicate.tokens.output'),
|
|
214
|
+
thinking_tokens: num(attrs, 'syndicate.tokens.thinking'),
|
|
215
|
+
latency_ms: durationMs(span),
|
|
216
|
+
model_ms: num(attrs, 'syndicate.latency.model_ms'),
|
|
217
|
+
tool_ms: num(attrs, 'syndicate.latency.tool_ms'),
|
|
218
|
+
llm_calls: num(attrs, 'syndicate.llm_calls'),
|
|
219
|
+
tool_calls: toolEvents.filter((e) => e.name === 'ToolCall').length,
|
|
220
|
+
models,
|
|
221
|
+
tool_events: toolEvents,
|
|
222
|
+
config_hash: str(attrs, 'syndicate.config_hash'),
|
|
223
|
+
engine_version: str(attrs, 'engine.version'),
|
|
224
|
+
eval_run: str(attrs, 'eval.run'),
|
|
225
|
+
eval_suite: str(attrs, 'eval.suite'),
|
|
226
|
+
eval_case: str(attrs, 'eval.case'),
|
|
227
|
+
eval_variant: str(attrs, 'eval.variant'),
|
|
228
|
+
eval_trial: typeof evalTrial === 'number' ? evalTrial : evalTrial ? Number(evalTrial) || null : null,
|
|
229
|
+
attributes: attrs,
|
|
230
|
+
schema_version: TELEMETRY_SCHEMA_VERSION,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** The facts a payload decision needs, read off a root span. */
|
|
234
|
+
export function turnFacts(span) {
|
|
235
|
+
const attrs = attrsOf(span);
|
|
236
|
+
const statusError = span.status?.code === 2; // SpanStatusCode.ERROR
|
|
237
|
+
return {
|
|
238
|
+
error: !!str(attrs, 'syndicate.error.code') || statusError,
|
|
239
|
+
fallback: bool(attrs, 'syndicate.relay_fallback') === true || bool(attrs, 'syndicate.route.fell_back') === true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** Map an ADK call_llm span into an adk_payloads row. */
|
|
243
|
+
export function toPayloadRow(span, reason, ttlDays, turn) {
|
|
244
|
+
const attrs = attrsOf(span);
|
|
245
|
+
const request = str(attrs, 'gcp.vertex.agent.llm_request') ?? '';
|
|
246
|
+
const response = str(attrs, 'gcp.vertex.agent.llm_response') ?? '';
|
|
247
|
+
const started = span.startTime[0] * 1000 + span.startTime[1] / 1e6;
|
|
248
|
+
return {
|
|
249
|
+
ts: new Date(started).toISOString(),
|
|
250
|
+
trace_id: span.spanContext().traceId,
|
|
251
|
+
span_id: span.spanContext().spanId,
|
|
252
|
+
session_id: turn?.sessionId ?? null,
|
|
253
|
+
invocation_id: str(attrs, 'gcp.vertex.agent.invocation_id') ?? turn?.invocationId ?? null,
|
|
254
|
+
agent: str(attrs, 'llm.agent') ?? str(attrs, 'gen_ai.agent.name'),
|
|
255
|
+
provider: str(attrs, 'gen_ai.system'),
|
|
256
|
+
model: str(attrs, 'gen_ai.request.model'),
|
|
257
|
+
reason,
|
|
258
|
+
request: parseLoose(request),
|
|
259
|
+
response: parseLoose(response),
|
|
260
|
+
request_chars: request.length,
|
|
261
|
+
response_chars: response.length,
|
|
262
|
+
expires_at: new Date(started + ttlDays * 86_400_000).toISOString(),
|
|
263
|
+
schema_version: TELEMETRY_SCHEMA_VERSION,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// ── The exporter ─────────────────────────────────────────────────────────────
|
|
267
|
+
/** True when the operator has opted into the Supabase ledger. */
|
|
23
268
|
export function telemetrySinkEnabled() {
|
|
24
269
|
return process.env.TELEMETRY_SUPABASE === 'true' && hasSupabaseCredentials();
|
|
25
270
|
}
|
|
26
|
-
|
|
271
|
+
export function deadLetterPath() {
|
|
272
|
+
return join(process.env.OUTPUTS_DIR ?? join(process.cwd(), 'outputs'), 'telemetry-deadletter.ndjson');
|
|
273
|
+
}
|
|
274
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
275
|
+
const DECISION_TTL_MS = 30 * 60 * 1000;
|
|
276
|
+
const PENDING_CAP = 5_000;
|
|
27
277
|
export class SupabaseSpanExporter {
|
|
28
|
-
/** Lazily created on first export so constructing the exporter is free
|
|
29
|
-
* and synchronous (NodeTracerProvider wants processors at construction). */
|
|
30
278
|
clientPromise;
|
|
31
|
-
|
|
279
|
+
policy;
|
|
280
|
+
deadLetterFile;
|
|
281
|
+
failureLogged = new Set();
|
|
32
282
|
pendingInserts = new Set();
|
|
283
|
+
/** call_llm spans waiting for their root span's outcome, by trace id. */
|
|
284
|
+
pendingPayloads = new Map();
|
|
285
|
+
/** Decisions already taken for a trace, for children arriving late. */
|
|
286
|
+
decisions = new Map();
|
|
287
|
+
pendingCount = 0;
|
|
288
|
+
stats = { inserted: {}, failed: {}, deadLettered: 0, payloadsDropped: 0 };
|
|
289
|
+
constructor(options = {}) {
|
|
290
|
+
this.policy = options.policy ?? payloadPolicyFromEnv();
|
|
291
|
+
this.deadLetterFile = options.deadLetterFile ?? deadLetterPath();
|
|
292
|
+
if (options.client)
|
|
293
|
+
this.clientPromise = Promise.resolve(options.client);
|
|
294
|
+
}
|
|
33
295
|
getClient() {
|
|
34
296
|
if (!this.clientPromise) {
|
|
35
297
|
this.clientPromise = import('@supabase/supabase-js').then(({ createClient }) => createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY));
|
|
@@ -37,36 +299,109 @@ export class SupabaseSpanExporter {
|
|
|
37
299
|
return this.clientPromise;
|
|
38
300
|
}
|
|
39
301
|
export(spans, resultCallback) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
302
|
+
const telemetryRows = [];
|
|
303
|
+
const turnRows = [];
|
|
304
|
+
const payloadRows = [];
|
|
305
|
+
for (const span of spans) {
|
|
306
|
+
if (EXPORTED_SPAN_NAMES.test(span.name))
|
|
307
|
+
telemetryRows.push(toTelemetryRow(span));
|
|
308
|
+
if (isRootSpan(span)) {
|
|
309
|
+
const turn = toTurnRow(span);
|
|
310
|
+
turnRows.push(turn);
|
|
311
|
+
const traceId = span.spanContext().traceId;
|
|
312
|
+
const reason = payloadDecision(this.policy, traceId, turnFacts(span));
|
|
313
|
+
const ctx = { sessionId: turn.session_id, invocationId: turn.invocation_id };
|
|
314
|
+
this.decisions.set(traceId, { at: Date.now(), reason, ...ctx });
|
|
315
|
+
const waiting = this.pendingPayloads.get(traceId);
|
|
316
|
+
if (waiting) {
|
|
317
|
+
this.pendingPayloads.delete(traceId);
|
|
318
|
+
this.pendingCount -= waiting.spans.length;
|
|
319
|
+
if (reason) {
|
|
320
|
+
for (const child of waiting.spans)
|
|
321
|
+
payloadRows.push(toPayloadRow(child, reason, this.policy.ttlDays, ctx));
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
this.stats.payloadsDropped += waiting.spans.length;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
58
328
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
329
|
+
if (this.policy.mode !== 'off' && isPayloadSpan(span)) {
|
|
330
|
+
const traceId = span.spanContext().traceId;
|
|
331
|
+
const decided = this.decisions.get(traceId);
|
|
332
|
+
if (decided) {
|
|
333
|
+
if (decided.reason)
|
|
334
|
+
payloadRows.push(toPayloadRow(span, decided.reason, this.policy.ttlDays, decided));
|
|
335
|
+
else
|
|
336
|
+
this.stats.payloadsDropped += 1;
|
|
337
|
+
}
|
|
338
|
+
else if (this.pendingCount < PENDING_CAP) {
|
|
339
|
+
const entry = this.pendingPayloads.get(traceId) ?? { at: Date.now(), spans: [] };
|
|
340
|
+
entry.spans.push(span);
|
|
341
|
+
this.pendingPayloads.set(traceId, entry);
|
|
342
|
+
this.pendingCount += 1;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
this.stats.payloadsDropped += 1;
|
|
346
|
+
}
|
|
64
347
|
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
348
|
+
}
|
|
349
|
+
this.pruneBuffers();
|
|
350
|
+
if (telemetryRows.length)
|
|
351
|
+
this.insert('adk_telemetry', telemetryRows);
|
|
352
|
+
if (turnRows.length)
|
|
353
|
+
this.insert('adk_turns', turnRows);
|
|
354
|
+
if (payloadRows.length)
|
|
355
|
+
this.insert('adk_payloads', payloadRows);
|
|
68
356
|
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
69
357
|
}
|
|
358
|
+
pruneBuffers() {
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
for (const [traceId, entry] of this.pendingPayloads) {
|
|
361
|
+
if (now - entry.at > PENDING_TTL_MS) {
|
|
362
|
+
this.pendingPayloads.delete(traceId);
|
|
363
|
+
this.pendingCount -= entry.spans.length;
|
|
364
|
+
this.stats.payloadsDropped += entry.spans.length;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const [traceId, d] of this.decisions) {
|
|
368
|
+
if (now - d.at > DECISION_TTL_MS)
|
|
369
|
+
this.decisions.delete(traceId);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Fire-and-forget insert; failures are logged once per table and spooled. */
|
|
373
|
+
insert(table, rows) {
|
|
374
|
+
const attempt = this.getClient()
|
|
375
|
+
.then((client) => client.from(table).insert(rows))
|
|
376
|
+
.then(({ error }) => {
|
|
377
|
+
if (error)
|
|
378
|
+
this.onFailure(table, rows, error.message);
|
|
379
|
+
else
|
|
380
|
+
this.stats.inserted[table] = (this.stats.inserted[table] ?? 0) + rows.length;
|
|
381
|
+
})
|
|
382
|
+
.catch((err) => this.onFailure(table, rows, err instanceof Error ? err.message : String(err)))
|
|
383
|
+
.finally(() => this.pendingInserts.delete(attempt));
|
|
384
|
+
this.pendingInserts.add(attempt);
|
|
385
|
+
}
|
|
386
|
+
onFailure(table, rows, message) {
|
|
387
|
+
this.stats.failed[table] = (this.stats.failed[table] ?? 0) + rows.length;
|
|
388
|
+
if (!this.failureLogged.has(table)) {
|
|
389
|
+
this.failureLogged.add(table);
|
|
390
|
+
console.warn(`[TELEMETRY] insert into ${table} failed (${message}). ` +
|
|
391
|
+
`Has db/telemetry.sql been applied? Rows are spooled to ${this.deadLetterFile || '(spool disabled)'}; ` +
|
|
392
|
+
'console [OTEL_SPAN_JSON] output is unaffected.');
|
|
393
|
+
}
|
|
394
|
+
if (!this.deadLetterFile)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
mkdirSync(join(this.deadLetterFile, '..'), { recursive: true });
|
|
398
|
+
appendFileSync(this.deadLetterFile, `${JSON.stringify({ ts: new Date().toISOString(), table, error: message, rows })}\n`);
|
|
399
|
+
this.stats.deadLettered += rows.length;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
/* the spool is best-effort; the warning above already fired */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
70
405
|
/** Waits for in-flight inserts so short-lived scripts don't drop rows. */
|
|
71
406
|
async shutdown() {
|
|
72
407
|
await Promise.allSettled([...this.pendingInserts]);
|
|
@@ -74,42 +409,4 @@ export class SupabaseSpanExporter {
|
|
|
74
409
|
async forceFlush() {
|
|
75
410
|
await Promise.allSettled([...this.pendingInserts]);
|
|
76
411
|
}
|
|
77
|
-
/** Flatten a span into one adk_telemetry row. */
|
|
78
|
-
toRow(span) {
|
|
79
|
-
const attrs = span.attributes;
|
|
80
|
-
const num = (key) => typeof attrs[key] === 'number' ? attrs[key] : null;
|
|
81
|
-
const str = (key) => typeof attrs[key] === 'string' ? attrs[key] : null;
|
|
82
|
-
// Root spans are named "Syndicate Execution: <name>"; llm.request spans
|
|
83
|
-
// carry syndicate context only if the caller set syndicate.name.
|
|
84
|
-
const syndicate = str('syndicate.name') ??
|
|
85
|
-
(span.name.startsWith('Syndicate Execution: ')
|
|
86
|
-
? span.name.slice('Syndicate Execution: '.length)
|
|
87
|
-
: null);
|
|
88
|
-
return {
|
|
89
|
-
ts: new Date(span.startTime[0] * 1000 + span.startTime[1] / 1e6).toISOString(),
|
|
90
|
-
trace_id: span.spanContext().traceId,
|
|
91
|
-
span_id: span.spanContext().spanId,
|
|
92
|
-
span_name: span.name,
|
|
93
|
-
syndicate,
|
|
94
|
-
agent: str('llm.agent'),
|
|
95
|
-
provider: str('llm.provider'),
|
|
96
|
-
model: str('llm.model'),
|
|
97
|
-
input_tokens: num('llm.tokens.input') ?? num('syndicate.tokens.input'),
|
|
98
|
-
output_tokens: num('llm.tokens.output') ?? num('syndicate.tokens.output'),
|
|
99
|
-
thinking_tokens: num('llm.tokens.thinking') ?? num('syndicate.tokens.thinking'),
|
|
100
|
-
latency_ms: span.duration[0] * 1000 + span.duration[1] / 1e6,
|
|
101
|
-
span: {
|
|
102
|
-
traceId: span.spanContext().traceId,
|
|
103
|
-
spanId: span.spanContext().spanId,
|
|
104
|
-
name: span.name,
|
|
105
|
-
durationMs: span.duration[0] * 1000 + span.duration[1] / 1e6,
|
|
106
|
-
attributes: attrs,
|
|
107
|
-
events: span.events.map((e) => ({
|
|
108
|
-
name: e.name,
|
|
109
|
-
attributes: e.attributes ?? {},
|
|
110
|
-
})),
|
|
111
|
-
status: span.status,
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
412
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
2
2
|
import type { Event, LlmResponse } from '@google/adk';
|
|
3
|
+
/**
|
|
4
|
+
* Optional OTLP export for a live trace viewer (Phoenix, Langfuse, Grafana
|
|
5
|
+
* Tempo, Jaeger — anything that speaks OTLP/HTTP). Set
|
|
6
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT (e.g. http://localhost:6006 for Phoenix) and
|
|
7
|
+
* every span — ADK's included, so the viewer gets the waterfall — is also
|
|
8
|
+
* sent there. OTEL_EXPORTER_OTLP_HEADERS is honoured by the exporter itself
|
|
9
|
+
* (api keys); OTEL_SERVICE_NAME names the service (default "melchizedek").
|
|
10
|
+
* Supabase stays the system of record; this is a viewer.
|
|
11
|
+
*/
|
|
12
|
+
export declare function otlpEndpoint(): string | undefined;
|
|
3
13
|
/**
|
|
4
14
|
* Flushes buffered span processors (console is synchronous; the Supabase
|
|
5
15
|
* batch sink is not). Call before process exit in short-lived scripts so
|
|
@@ -8,11 +18,74 @@ import type { Event, LlmResponse } from '@google/adk';
|
|
|
8
18
|
export declare function flushTracing(): Promise<void>;
|
|
9
19
|
/** Subscribe to finished spans. Returns an unsubscribe function. */
|
|
10
20
|
export declare function onSpanEnd(listener: (span: ReadableSpan) => void): () => void;
|
|
21
|
+
/**
|
|
22
|
+
* Per-trace accumulators, filled as child spans END and read by the root
|
|
23
|
+
* span in its own finally: how much of a turn was model time vs tool time,
|
|
24
|
+
* how many model calls it took, which models answered. The root turns
|
|
25
|
+
* these into `syndicate.latency.model_ms` / `.tool_ms` / `syndicate.llm_calls`
|
|
26
|
+
* / `syndicate.models`, so a p95 regression is attributable without
|
|
27
|
+
* reassembling child spans at query time.
|
|
28
|
+
*/
|
|
29
|
+
interface TraceStats {
|
|
30
|
+
modelMs: number;
|
|
31
|
+
toolMs: number;
|
|
32
|
+
llmCalls: number;
|
|
33
|
+
models: Set<string>;
|
|
34
|
+
startedAt: number;
|
|
35
|
+
}
|
|
36
|
+
/** Read-and-forget the stats for a finished trace. */
|
|
37
|
+
export declare function takeTraceStats(traceId: string): TraceStats | undefined;
|
|
38
|
+
/** Name of the nearest enclosing ADK `invoke_agent` span, or null. */
|
|
39
|
+
export declare function agentForSpan(spanId: string | undefined): string | null;
|
|
11
40
|
export declare function initializeTracing(): void;
|
|
12
41
|
export interface TraceMetadata {
|
|
13
42
|
syndicateName: string;
|
|
14
43
|
bindings?: Record<string, any>;
|
|
15
44
|
input?: any;
|
|
45
|
+
/**
|
|
46
|
+
* The plan-dispatch resolution this turn runs under, recorded on the root
|
|
47
|
+
* span as `syndicate.route*` attributes. Without it the chosen route was
|
|
48
|
+
* only ever a console line, so routing accuracy could not be scored from
|
|
49
|
+
* stored telemetry.
|
|
50
|
+
*/
|
|
51
|
+
route?: {
|
|
52
|
+
route: string;
|
|
53
|
+
reason?: string;
|
|
54
|
+
fellBack?: boolean;
|
|
55
|
+
viaOverride?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Extra root-span attributes. The observatory tags eval traffic with
|
|
59
|
+
* `eval.suite` / `eval.case` / `eval.variant` / `eval.trial` / `eval.run`
|
|
60
|
+
* so post-deployment suites can exclude it from production traces.
|
|
61
|
+
*/
|
|
62
|
+
attributes?: Record<string, string | number | boolean>;
|
|
63
|
+
/** Receives the root span's ids as soon as it starts (before any child). */
|
|
64
|
+
onSpanStart?: (ids: {
|
|
65
|
+
traceId: string;
|
|
66
|
+
spanId: string;
|
|
67
|
+
}) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Identity of the turn. These are what make a stored turn joinable to
|
|
70
|
+
* its session row (`adk_sessions.id` = app:user:session) and to the ADK
|
|
71
|
+
* events of the same invocation; without them a trace and a session can
|
|
72
|
+
* only be matched by timestamp and luck.
|
|
73
|
+
*/
|
|
74
|
+
sessionId?: string;
|
|
75
|
+
userId?: string;
|
|
76
|
+
taskId?: string;
|
|
77
|
+
/** 'delegate' | 'dispatch' | 'classify' | 'judge' — which kind of turn. */
|
|
78
|
+
stage?: string;
|
|
79
|
+
/** Provenance (lib/observability/lineage.ts): the resolved-config hash. */
|
|
80
|
+
configHash?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Called once, after the last event and before the span ends, to add
|
|
83
|
+
* attributes only the consumer can know at that point — the A2A executor
|
|
84
|
+
* reports whether the DELEGATE relay fallback fired, a fact decided from
|
|
85
|
+
* the drained text and tool names. Runs inside the generator's finally,
|
|
86
|
+
* so every event has been processed by the time it is asked.
|
|
87
|
+
*/
|
|
88
|
+
onEnd?: () => Record<string, string | number | boolean | undefined>;
|
|
16
89
|
}
|
|
17
90
|
/**
|
|
18
91
|
* Wraps an ADK `Runner.runAsync` stream with a root OpenTelemetry span.
|
|
@@ -42,3 +115,4 @@ export declare function traceLlmGeneration(meta: LlmCallMeta, inner: AsyncGenera
|
|
|
42
115
|
* Used by adapters for per-request flags like llm.web_search.omitted.
|
|
43
116
|
*/
|
|
44
117
|
export declare function setLlmSpanAttribute(key: string, value: string | number | boolean): void;
|
|
118
|
+
export {};
|