@springbrand/agent-runtime 0.2.0-alpha.15 → 0.2.0-alpha.17
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/package.json +1 -1
- package/src/adapter/cloudflare/sandbox/adapter.ts +61 -36
- package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -2
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +23 -18
- package/src/db/index.ts +5 -0
- package/src/db/schema.ts +15 -0
- package/src/db/telemetry-outbox.repo.ts +151 -0
- package/src/index.ts +1 -0
- package/src/kernel/approval-lifecycle.ts +35 -3
- package/src/kernel/bindings.ts +2 -1
- package/src/kernel/interaction-lifecycle.ts +35 -6
- package/src/layers/orchestration/temporary-agent/workspace.ts +4 -4
- package/src/lib/prompt.ts +22 -15
- package/src/pi/assembly/context.ts +2 -2
- package/src/pi/message/conversion.ts +13 -1
- package/src/pi/runtime-adapter/assembly.ts +1 -0
- package/src/pi/runtime-adapter/execution.ts +63 -27
- package/src/pi/runtime-adapter/models.ts +144 -44
- package/src/pi/tool/ai-adapter.ts +2 -2
- package/src/pi/tool/base.ts +31 -25
- package/src/pi/tool/compiler.ts +6 -102
- package/src/pi/turn/tool-recovery.ts +11 -1
- package/src/runtime-agent.ts +24 -0
- package/src/runtime-assembler.ts +38 -19
- package/src/runtime-definition.ts +2 -0
- package/src/runtime.ts +362 -20
- package/src/telemetry/contract.ts +389 -0
- package/src/telemetry/coordinator.ts +143 -0
- package/src/telemetry/delivery.ts +138 -0
- package/src/telemetry/ids.ts +60 -0
- package/src/telemetry/index.ts +7 -0
- package/src/telemetry/recorder.ts +61 -0
- package/src/telemetry/runtime-telemetry.ts +484 -0
- package/src/telemetry/sanitize.ts +97 -0
- package/src/tool-registry.ts +18 -11
- package/src/lib/telemetry-dev.ts +0 -47
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/** JSON values accepted by the vendor-neutral telemetry contract. */
|
|
2
|
+
export type TelemetryValue =
|
|
3
|
+
| string
|
|
4
|
+
| number
|
|
5
|
+
| boolean
|
|
6
|
+
| null
|
|
7
|
+
| readonly TelemetryValue[]
|
|
8
|
+
| { readonly [key: string]: TelemetryValue };
|
|
9
|
+
|
|
10
|
+
export type TelemetryAttributes = Readonly<Record<string, TelemetryValue>>;
|
|
11
|
+
|
|
12
|
+
export type TelemetryPrivacyMode = "metadata" | "content";
|
|
13
|
+
|
|
14
|
+
/** Identity attached to every event before it crosses the Runtime boundary. */
|
|
15
|
+
export interface UniversalTelemetryResource {
|
|
16
|
+
readonly serviceName: string;
|
|
17
|
+
readonly serviceVersion?: string;
|
|
18
|
+
readonly environment?: string;
|
|
19
|
+
readonly agentName: string;
|
|
20
|
+
readonly agentId: string;
|
|
21
|
+
readonly sessionId: string;
|
|
22
|
+
readonly attributes?: TelemetryAttributes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const UNIVERSAL_TELEMETRY_EVENT_TYPES = [
|
|
26
|
+
"turn.admitted",
|
|
27
|
+
"turn.started",
|
|
28
|
+
"turn.steered",
|
|
29
|
+
"turn.recovering",
|
|
30
|
+
"turn.cancelled",
|
|
31
|
+
"turn.finished",
|
|
32
|
+
"generation.started",
|
|
33
|
+
"generation.finished",
|
|
34
|
+
"tool.started",
|
|
35
|
+
"tool.finished",
|
|
36
|
+
"approval.requested",
|
|
37
|
+
"approval.decided",
|
|
38
|
+
"interaction.requested",
|
|
39
|
+
"interaction.responded",
|
|
40
|
+
"interaction.cancelled",
|
|
41
|
+
"subagent.started",
|
|
42
|
+
"subagent.finished",
|
|
43
|
+
"context.compacted",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type UniversalTelemetryEventType =
|
|
47
|
+
typeof UNIVERSAL_TELEMETRY_EVENT_TYPES[number];
|
|
48
|
+
|
|
49
|
+
interface PayloadBase {
|
|
50
|
+
readonly attributes?: TelemetryAttributes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TelemetryPayloadByType {
|
|
54
|
+
readonly "turn.admitted": PayloadBase & {
|
|
55
|
+
readonly requestId: string;
|
|
56
|
+
readonly idempotencyKey?: string;
|
|
57
|
+
};
|
|
58
|
+
readonly "turn.started": PayloadBase & {
|
|
59
|
+
readonly assemblyRevision?: string;
|
|
60
|
+
readonly input?: TelemetryValue;
|
|
61
|
+
};
|
|
62
|
+
readonly "turn.steered": PayloadBase & {
|
|
63
|
+
readonly steerId: string;
|
|
64
|
+
readonly messageId?: string;
|
|
65
|
+
readonly content?: TelemetryValue;
|
|
66
|
+
};
|
|
67
|
+
readonly "turn.recovering": PayloadBase & {
|
|
68
|
+
readonly reason: string;
|
|
69
|
+
readonly attempt: number;
|
|
70
|
+
readonly error?: string;
|
|
71
|
+
};
|
|
72
|
+
readonly "turn.cancelled": PayloadBase & {
|
|
73
|
+
readonly reason?: string;
|
|
74
|
+
};
|
|
75
|
+
readonly "turn.finished": PayloadBase & {
|
|
76
|
+
readonly outcome: "completed" | "failed" | "cancelled";
|
|
77
|
+
readonly durationMs?: number;
|
|
78
|
+
readonly output?: TelemetryValue;
|
|
79
|
+
readonly error?: string;
|
|
80
|
+
};
|
|
81
|
+
readonly "generation.started": PayloadBase & {
|
|
82
|
+
readonly generationId: string;
|
|
83
|
+
readonly provider?: string;
|
|
84
|
+
readonly model?: string;
|
|
85
|
+
readonly input?: TelemetryValue;
|
|
86
|
+
};
|
|
87
|
+
readonly "generation.finished": PayloadBase & {
|
|
88
|
+
readonly generationId: string;
|
|
89
|
+
readonly outcome: "completed" | "failed" | "cancelled";
|
|
90
|
+
readonly durationMs?: number;
|
|
91
|
+
readonly inputTokens?: number;
|
|
92
|
+
readonly outputTokens?: number;
|
|
93
|
+
readonly totalTokens?: number;
|
|
94
|
+
readonly cost?: number;
|
|
95
|
+
readonly output?: TelemetryValue;
|
|
96
|
+
readonly error?: string;
|
|
97
|
+
};
|
|
98
|
+
readonly "tool.started": PayloadBase & {
|
|
99
|
+
readonly toolName: string;
|
|
100
|
+
readonly input?: TelemetryValue;
|
|
101
|
+
};
|
|
102
|
+
readonly "tool.finished": PayloadBase & {
|
|
103
|
+
readonly toolName: string;
|
|
104
|
+
readonly outcome: "completed" | "failed" | "cancelled";
|
|
105
|
+
readonly durationMs?: number;
|
|
106
|
+
readonly outputBytes?: number;
|
|
107
|
+
readonly spilled?: boolean;
|
|
108
|
+
readonly output?: TelemetryValue;
|
|
109
|
+
readonly error?: string;
|
|
110
|
+
};
|
|
111
|
+
readonly "approval.requested": PayloadBase & {
|
|
112
|
+
readonly executionId: string;
|
|
113
|
+
readonly toolName: string;
|
|
114
|
+
readonly summary?: string;
|
|
115
|
+
readonly input?: TelemetryValue;
|
|
116
|
+
};
|
|
117
|
+
readonly "approval.decided": PayloadBase & {
|
|
118
|
+
readonly executionId: string;
|
|
119
|
+
readonly decision: "approved" | "rejected" | "cancelled";
|
|
120
|
+
readonly reason?: string;
|
|
121
|
+
};
|
|
122
|
+
readonly "interaction.requested": PayloadBase & {
|
|
123
|
+
readonly interactionId: string;
|
|
124
|
+
readonly toolName: string;
|
|
125
|
+
readonly input?: TelemetryValue;
|
|
126
|
+
};
|
|
127
|
+
readonly "interaction.responded": PayloadBase & {
|
|
128
|
+
readonly interactionId: string;
|
|
129
|
+
readonly response?: TelemetryValue;
|
|
130
|
+
};
|
|
131
|
+
readonly "interaction.cancelled": PayloadBase & {
|
|
132
|
+
readonly interactionId: string;
|
|
133
|
+
readonly reason?: string;
|
|
134
|
+
};
|
|
135
|
+
readonly "subagent.started": PayloadBase & {
|
|
136
|
+
readonly subagentId: string;
|
|
137
|
+
readonly agentType?: string;
|
|
138
|
+
readonly prompt?: TelemetryValue;
|
|
139
|
+
};
|
|
140
|
+
readonly "subagent.finished": PayloadBase & {
|
|
141
|
+
readonly subagentId: string;
|
|
142
|
+
readonly outcome: "completed" | "failed" | "cancelled";
|
|
143
|
+
readonly durationMs?: number;
|
|
144
|
+
readonly output?: TelemetryValue;
|
|
145
|
+
readonly error?: string;
|
|
146
|
+
};
|
|
147
|
+
readonly "context.compacted": PayloadBase & {
|
|
148
|
+
readonly compactionId: string;
|
|
149
|
+
readonly inputTokens?: number;
|
|
150
|
+
readonly outputTokens?: number;
|
|
151
|
+
readonly removedMessages?: number;
|
|
152
|
+
readonly summary?: TelemetryValue;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type UniversalTelemetryEvent<
|
|
157
|
+
T extends UniversalTelemetryEventType = UniversalTelemetryEventType,
|
|
158
|
+
> = T extends UniversalTelemetryEventType ? {
|
|
159
|
+
readonly schemaVersion: 1;
|
|
160
|
+
readonly type: T;
|
|
161
|
+
readonly eventId: string;
|
|
162
|
+
readonly operationId: string;
|
|
163
|
+
readonly parentOperationId?: string;
|
|
164
|
+
readonly submissionId?: string;
|
|
165
|
+
readonly toolCallId?: string;
|
|
166
|
+
readonly occurredAt: number;
|
|
167
|
+
readonly resource: UniversalTelemetryResource;
|
|
168
|
+
readonly payload: TelemetryPayloadByType[T];
|
|
169
|
+
}
|
|
170
|
+
: never;
|
|
171
|
+
|
|
172
|
+
export type NewUniversalTelemetryEvent<
|
|
173
|
+
T extends UniversalTelemetryEventType,
|
|
174
|
+
> = Omit<UniversalTelemetryEvent<T>, "schemaVersion">;
|
|
175
|
+
|
|
176
|
+
/** The only delivery boundary owned by Universal Agent. */
|
|
177
|
+
export interface AgentTelemetryPort {
|
|
178
|
+
publish(events: readonly UniversalTelemetryEvent[]): Promise<void>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Fully resolved telemetry choice carried by an assembled Runtime candidate. */
|
|
182
|
+
export interface AgentTelemetryBinding {
|
|
183
|
+
readonly port: AgentTelemetryPort;
|
|
184
|
+
readonly resource: UniversalTelemetryResource;
|
|
185
|
+
readonly mode: TelemetryPrivacyMode;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const EVENT_TYPE_SET = new Set<string>(UNIVERSAL_TELEMETRY_EVENT_TYPES);
|
|
189
|
+
|
|
190
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
191
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requireNonEmptyString(
|
|
195
|
+
value: unknown,
|
|
196
|
+
field: string,
|
|
197
|
+
): asserts value is string {
|
|
198
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
199
|
+
throw new TypeError(`Telemetry ${field} must be a non-empty string`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requireFiniteNumber(value: unknown, field: string): void {
|
|
204
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
205
|
+
throw new TypeError(`Telemetry ${field} must be a finite number`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requireOneOf(
|
|
210
|
+
value: unknown,
|
|
211
|
+
allowed: readonly string[],
|
|
212
|
+
field: string,
|
|
213
|
+
): void {
|
|
214
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
`Telemetry ${field} must be one of: ${allowed.join(", ")}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertPayload(
|
|
222
|
+
type: UniversalTelemetryEventType,
|
|
223
|
+
payload: Record<string, unknown>,
|
|
224
|
+
): void {
|
|
225
|
+
switch (type) {
|
|
226
|
+
case "turn.admitted":
|
|
227
|
+
requireNonEmptyString(payload.requestId, "payload.requestId");
|
|
228
|
+
break;
|
|
229
|
+
case "turn.steered":
|
|
230
|
+
requireNonEmptyString(payload.steerId, "payload.steerId");
|
|
231
|
+
break;
|
|
232
|
+
case "turn.recovering":
|
|
233
|
+
requireNonEmptyString(payload.reason, "payload.reason");
|
|
234
|
+
requireFiniteNumber(payload.attempt, "payload.attempt");
|
|
235
|
+
break;
|
|
236
|
+
case "turn.finished":
|
|
237
|
+
case "generation.finished":
|
|
238
|
+
case "tool.finished":
|
|
239
|
+
case "subagent.finished":
|
|
240
|
+
requireOneOf(
|
|
241
|
+
payload.outcome,
|
|
242
|
+
["completed", "failed", "cancelled"],
|
|
243
|
+
"payload.outcome",
|
|
244
|
+
);
|
|
245
|
+
if (type === "generation.finished") {
|
|
246
|
+
requireNonEmptyString(payload.generationId, "payload.generationId");
|
|
247
|
+
} else if (type === "tool.finished") {
|
|
248
|
+
requireNonEmptyString(payload.toolName, "payload.toolName");
|
|
249
|
+
} else if (type === "subagent.finished") {
|
|
250
|
+
requireNonEmptyString(payload.subagentId, "payload.subagentId");
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
case "generation.started":
|
|
254
|
+
requireNonEmptyString(payload.generationId, "payload.generationId");
|
|
255
|
+
break;
|
|
256
|
+
case "tool.started":
|
|
257
|
+
requireNonEmptyString(payload.toolName, "payload.toolName");
|
|
258
|
+
break;
|
|
259
|
+
case "approval.requested":
|
|
260
|
+
requireNonEmptyString(payload.executionId, "payload.executionId");
|
|
261
|
+
requireNonEmptyString(payload.toolName, "payload.toolName");
|
|
262
|
+
break;
|
|
263
|
+
case "approval.decided":
|
|
264
|
+
requireNonEmptyString(payload.executionId, "payload.executionId");
|
|
265
|
+
requireOneOf(
|
|
266
|
+
payload.decision,
|
|
267
|
+
["approved", "rejected", "cancelled"],
|
|
268
|
+
"payload.decision",
|
|
269
|
+
);
|
|
270
|
+
break;
|
|
271
|
+
case "interaction.requested":
|
|
272
|
+
requireNonEmptyString(payload.interactionId, "payload.interactionId");
|
|
273
|
+
requireNonEmptyString(payload.toolName, "payload.toolName");
|
|
274
|
+
break;
|
|
275
|
+
case "interaction.responded":
|
|
276
|
+
case "interaction.cancelled":
|
|
277
|
+
requireNonEmptyString(payload.interactionId, "payload.interactionId");
|
|
278
|
+
break;
|
|
279
|
+
case "subagent.started":
|
|
280
|
+
requireNonEmptyString(payload.subagentId, "payload.subagentId");
|
|
281
|
+
break;
|
|
282
|
+
case "context.compacted":
|
|
283
|
+
requireNonEmptyString(payload.compactionId, "payload.compactionId");
|
|
284
|
+
break;
|
|
285
|
+
case "turn.started":
|
|
286
|
+
case "turn.cancelled":
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
for (const field of [
|
|
290
|
+
"durationMs",
|
|
291
|
+
"inputTokens",
|
|
292
|
+
"outputTokens",
|
|
293
|
+
"totalTokens",
|
|
294
|
+
"cost",
|
|
295
|
+
"outputBytes",
|
|
296
|
+
"removedMessages",
|
|
297
|
+
]) {
|
|
298
|
+
const value = payload[field];
|
|
299
|
+
if (value !== undefined) {
|
|
300
|
+
requireFiniteNumber(value, `payload.${field}`);
|
|
301
|
+
if ((value as number) < 0) {
|
|
302
|
+
throw new TypeError(`Telemetry payload.${field} cannot be negative`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function assertTelemetryResource(
|
|
309
|
+
value: unknown,
|
|
310
|
+
): asserts value is UniversalTelemetryResource {
|
|
311
|
+
if (!isRecord(value)) {
|
|
312
|
+
throw new TypeError("Telemetry resource must be an object");
|
|
313
|
+
}
|
|
314
|
+
for (const field of ["serviceName", "agentName", "agentId", "sessionId"] as const) {
|
|
315
|
+
requireNonEmptyString(value[field], `resource.${field}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Fail closed before malformed data is persisted or handed to a consumer. */
|
|
320
|
+
export function assertUniversalTelemetryEvent(
|
|
321
|
+
value: unknown,
|
|
322
|
+
): asserts value is UniversalTelemetryEvent {
|
|
323
|
+
if (!isRecord(value)) throw new TypeError("Telemetry event must be an object");
|
|
324
|
+
if (value.schemaVersion !== 1) {
|
|
325
|
+
throw new TypeError("Telemetry schemaVersion must be 1");
|
|
326
|
+
}
|
|
327
|
+
if (typeof value.type !== "string" || !EVENT_TYPE_SET.has(value.type)) {
|
|
328
|
+
throw new TypeError(`Unknown telemetry event type: ${String(value.type)}`);
|
|
329
|
+
}
|
|
330
|
+
requireNonEmptyString(value.eventId, "eventId");
|
|
331
|
+
requireNonEmptyString(value.operationId, "operationId");
|
|
332
|
+
if (value.parentOperationId !== undefined) {
|
|
333
|
+
requireNonEmptyString(value.parentOperationId, "parentOperationId");
|
|
334
|
+
}
|
|
335
|
+
if (value.submissionId !== undefined) {
|
|
336
|
+
requireNonEmptyString(value.submissionId, "submissionId");
|
|
337
|
+
}
|
|
338
|
+
if (value.toolCallId !== undefined) {
|
|
339
|
+
requireNonEmptyString(value.toolCallId, "toolCallId");
|
|
340
|
+
}
|
|
341
|
+
if (typeof value.occurredAt !== "number" || !Number.isFinite(value.occurredAt)) {
|
|
342
|
+
throw new TypeError("Telemetry occurredAt must be a finite number");
|
|
343
|
+
}
|
|
344
|
+
assertTelemetryResource(value.resource);
|
|
345
|
+
if (!isRecord(value.payload)) {
|
|
346
|
+
throw new TypeError("Telemetry payload must be an object");
|
|
347
|
+
}
|
|
348
|
+
assertPayload(value.type as UniversalTelemetryEventType, value.payload);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function assertAgentTelemetryBinding(
|
|
352
|
+
value: unknown,
|
|
353
|
+
): asserts value is AgentTelemetryBinding {
|
|
354
|
+
if (!isRecord(value)) throw new TypeError("Telemetry binding must be an object");
|
|
355
|
+
assertTelemetryResource(value.resource);
|
|
356
|
+
if (value.mode !== "metadata" && value.mode !== "content") {
|
|
357
|
+
throw new TypeError("Telemetry mode must be metadata or content");
|
|
358
|
+
}
|
|
359
|
+
if (!isRecord(value.port) || typeof value.port.publish !== "function") {
|
|
360
|
+
throw new TypeError("Telemetry port must implement publish(events)");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Validate and detach mutable resource input at the assembly boundary. */
|
|
365
|
+
export function normalizeAgentTelemetryBinding(
|
|
366
|
+
binding: AgentTelemetryBinding,
|
|
367
|
+
): AgentTelemetryBinding {
|
|
368
|
+
assertAgentTelemetryBinding(binding);
|
|
369
|
+
const attributes = binding.resource.attributes === undefined
|
|
370
|
+
? undefined
|
|
371
|
+
: Object.freeze({ ...binding.resource.attributes });
|
|
372
|
+
const resource = Object.freeze({
|
|
373
|
+
...binding.resource,
|
|
374
|
+
...(attributes === undefined ? {} : { attributes }),
|
|
375
|
+
});
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
port: binding.port,
|
|
378
|
+
resource,
|
|
379
|
+
mode: binding.mode,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function createUniversalTelemetryEvent<
|
|
384
|
+
T extends UniversalTelemetryEventType,
|
|
385
|
+
>(input: NewUniversalTelemetryEvent<T>): UniversalTelemetryEvent<T> {
|
|
386
|
+
const event = { ...input, schemaVersion: 1 } as UniversalTelemetryEvent<T>;
|
|
387
|
+
assertUniversalTelemetryEvent(event);
|
|
388
|
+
return event;
|
|
389
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { TelemetryDeliveryOutbox } from "./delivery";
|
|
2
|
+
import { TELEMETRY_RETRY_DELAY_MS, TelemetryDelivery } from "./delivery";
|
|
3
|
+
import type { TelemetryOutboxWriter } from "./recorder";
|
|
4
|
+
import { TelemetryRecorder } from "./recorder";
|
|
5
|
+
import { RuntimeTelemetry } from "./runtime-telemetry";
|
|
6
|
+
import type { AgentTelemetryBinding } from "./contract";
|
|
7
|
+
|
|
8
|
+
const TELEMETRY_COLD_BUFFER_LIMIT = 1_000;
|
|
9
|
+
|
|
10
|
+
type RuntimeTelemetryMethod = {
|
|
11
|
+
[Name in keyof RuntimeTelemetry]: RuntimeTelemetry[Name] extends
|
|
12
|
+
(input: infer _Input) => unknown ? Name : never;
|
|
13
|
+
}[keyof RuntimeTelemetry];
|
|
14
|
+
|
|
15
|
+
type RuntimeTelemetryInput<Name extends RuntimeTelemetryMethod> =
|
|
16
|
+
RuntimeTelemetry[Name] extends (input: infer Input) => unknown ? Input : never;
|
|
17
|
+
|
|
18
|
+
type RuntimeTelemetryOutbox = TelemetryDeliveryOutbox & TelemetryOutboxWriter;
|
|
19
|
+
|
|
20
|
+
interface BufferedTelemetryFact {
|
|
21
|
+
readonly method: RuntimeTelemetryMethod;
|
|
22
|
+
readonly input: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RuntimeTelemetryCoordinatorOptions {
|
|
26
|
+
readonly outbox: () => RuntimeTelemetryOutbox | undefined;
|
|
27
|
+
readonly waitUntil: (promise: Promise<void>) => void;
|
|
28
|
+
readonly scheduleRetry: (
|
|
29
|
+
delaySeconds: number,
|
|
30
|
+
idempotent: boolean,
|
|
31
|
+
) => Promise<void>;
|
|
32
|
+
readonly onError?: (error: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function detached<T>(input: T): T {
|
|
36
|
+
try {
|
|
37
|
+
return structuredClone(input);
|
|
38
|
+
} catch {
|
|
39
|
+
return input;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Owns Runtime telemetry activation, pre-activation buffering and delivery.
|
|
45
|
+
*
|
|
46
|
+
* Fact owners only call {@link capture}; consumer absence, retries and failures
|
|
47
|
+
* remain invisible to authoritative Runtime state transitions.
|
|
48
|
+
*/
|
|
49
|
+
export class RuntimeTelemetryCoordinator {
|
|
50
|
+
private runtime?: RuntimeTelemetry;
|
|
51
|
+
private delivery?: TelemetryDelivery;
|
|
52
|
+
private configured = false;
|
|
53
|
+
private draining?: Promise<void>;
|
|
54
|
+
private readonly coldBuffer: BufferedTelemetryFact[] = [];
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly options: RuntimeTelemetryCoordinatorOptions,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
configure(binding?: AgentTelemetryBinding): void {
|
|
61
|
+
if (this.configured) return;
|
|
62
|
+
this.configured = true;
|
|
63
|
+
if (!binding) {
|
|
64
|
+
this.coldBuffer.length = 0;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const outbox = this.options.outbox();
|
|
68
|
+
if (!outbox) {
|
|
69
|
+
this.coldBuffer.length = 0;
|
|
70
|
+
this.options.onError?.(new Error("Telemetry outbox is unavailable"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.runtime = new RuntimeTelemetry(
|
|
74
|
+
binding.resource,
|
|
75
|
+
new TelemetryRecorder(outbox, binding.mode),
|
|
76
|
+
);
|
|
77
|
+
this.delivery = new TelemetryDelivery(outbox, binding.port);
|
|
78
|
+
for (const fact of this.coldBuffer.splice(0)) this.deliver(fact);
|
|
79
|
+
if (outbox.hasPending()) this.queueDrain();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
capture<Name extends RuntimeTelemetryMethod>(
|
|
83
|
+
method: Name,
|
|
84
|
+
input: RuntimeTelemetryInput<Name>,
|
|
85
|
+
): void {
|
|
86
|
+
if (this.runtime) {
|
|
87
|
+
this.deliver({ method, input });
|
|
88
|
+
this.queueDrain();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!this.configured && this.coldBuffer.length < TELEMETRY_COLD_BUFFER_LIMIT) {
|
|
92
|
+
this.coldBuffer.push({ method, input: detached(input) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
recover(ensureReady: () => Promise<void>): void {
|
|
97
|
+
if (!this.options.outbox()?.hasPending()) return;
|
|
98
|
+
this.options.waitUntil(
|
|
99
|
+
ensureReady().then(() => this.drain()),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async drainScheduled(): Promise<void> {
|
|
104
|
+
await this.drain(false);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private deliver(fact: BufferedTelemetryFact): void {
|
|
108
|
+
if (!this.runtime) return;
|
|
109
|
+
try {
|
|
110
|
+
const invoke = this.runtime[fact.method] as unknown as (
|
|
111
|
+
input: unknown,
|
|
112
|
+
) => unknown;
|
|
113
|
+
invoke.call(this.runtime, fact.input);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
this.options.onError?.(error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private queueDrain(): void {
|
|
120
|
+
if (!this.delivery || this.draining) return;
|
|
121
|
+
const pending = this.drain();
|
|
122
|
+
let tracked: Promise<void>;
|
|
123
|
+
tracked = pending.finally(() => {
|
|
124
|
+
if (this.draining === tracked) this.draining = undefined;
|
|
125
|
+
});
|
|
126
|
+
this.draining = tracked;
|
|
127
|
+
this.options.waitUntil(tracked);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async drain(idempotentRetry = true): Promise<void> {
|
|
131
|
+
if (!this.delivery) return;
|
|
132
|
+
try {
|
|
133
|
+
const result = await this.delivery.drain();
|
|
134
|
+
if (!result.hasPending) return;
|
|
135
|
+
await this.options.scheduleRetry(
|
|
136
|
+
TELEMETRY_RETRY_DELAY_MS / 1_000,
|
|
137
|
+
idempotentRetry,
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
this.options.onError?.(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StoredTelemetryOutboxEvent,
|
|
3
|
+
} from "../db/telemetry-outbox.repo";
|
|
4
|
+
import {
|
|
5
|
+
TELEMETRY_OUTBOX_BATCH_SIZE,
|
|
6
|
+
} from "../db/telemetry-outbox.repo";
|
|
7
|
+
import {
|
|
8
|
+
assertUniversalTelemetryEvent,
|
|
9
|
+
type AgentTelemetryPort,
|
|
10
|
+
type UniversalTelemetryEvent,
|
|
11
|
+
} from "./contract";
|
|
12
|
+
|
|
13
|
+
export const TELEMETRY_BATCH_FAILURE_LIMIT = 5;
|
|
14
|
+
export const TELEMETRY_DELIVERED_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
15
|
+
export const TELEMETRY_RETRY_DELAY_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
export interface TelemetryDeliveryOutbox {
|
|
18
|
+
listPending(limit?: number): StoredTelemetryOutboxEvent[];
|
|
19
|
+
markDelivered(eventIds: readonly string[], deliveredAt: number): void;
|
|
20
|
+
markFailed(eventIds: readonly string[], error: string): void;
|
|
21
|
+
quarantine(eventId: string, quarantinedAt: number, error: string): void;
|
|
22
|
+
deleteDeliveredBefore(cutoff: number): void;
|
|
23
|
+
hasPending(): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TelemetryDeliveryResult {
|
|
27
|
+
readonly selected: number;
|
|
28
|
+
readonly delivered: number;
|
|
29
|
+
readonly quarantined: number;
|
|
30
|
+
readonly failed: number;
|
|
31
|
+
readonly hasPending: boolean;
|
|
32
|
+
readonly retryAfterMs: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorText(error: unknown): string {
|
|
36
|
+
const value = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return value.length <= 1_000 ? value : `${value.slice(0, 1_000)}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parse(row: StoredTelemetryOutboxEvent): UniversalTelemetryEvent {
|
|
41
|
+
const event: unknown = JSON.parse(row.body);
|
|
42
|
+
assertUniversalTelemetryEvent(event);
|
|
43
|
+
if (event.eventId !== row.eventId) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Telemetry outbox identity mismatch: ${row.eventId} != ${event.eventId}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return event;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Delivers one bounded batch. Ack happens only after publish resolves, so a
|
|
53
|
+
* crash between the remote write and local ack intentionally causes replay.
|
|
54
|
+
*/
|
|
55
|
+
export class TelemetryDelivery {
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly outbox: TelemetryDeliveryOutbox,
|
|
58
|
+
private readonly port: AgentTelemetryPort,
|
|
59
|
+
private readonly now: () => number = Date.now,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
async drain(): Promise<TelemetryDeliveryResult> {
|
|
63
|
+
const now = this.now();
|
|
64
|
+
this.outbox.deleteDeliveredBefore(now - TELEMETRY_DELIVERED_RETENTION_MS);
|
|
65
|
+
const rows = this.outbox.listPending(TELEMETRY_OUTBOX_BATCH_SIZE);
|
|
66
|
+
let quarantined = 0;
|
|
67
|
+
const valid: Array<{
|
|
68
|
+
row: StoredTelemetryOutboxEvent;
|
|
69
|
+
event: UniversalTelemetryEvent;
|
|
70
|
+
}> = [];
|
|
71
|
+
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
try {
|
|
74
|
+
valid.push({ row, event: parse(row) });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.outbox.quarantine(row.eventId, now, errorText(error));
|
|
77
|
+
quarantined += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (valid.length === 0) {
|
|
82
|
+
return this.result(rows.length, 0, quarantined, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await this.port.publish(valid.map(({ event }) => event));
|
|
87
|
+
this.outbox.markDelivered(valid.map(({ row }) => row.eventId), now);
|
|
88
|
+
return this.result(rows.length, valid.length, quarantined, 0);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const failure = errorText(error);
|
|
91
|
+
const ids = valid.map(({ row }) => row.eventId);
|
|
92
|
+
this.outbox.markFailed(ids, failure);
|
|
93
|
+
const shouldIsolate = valid.some(
|
|
94
|
+
({ row }) => row.attemptCount + 1 >= TELEMETRY_BATCH_FAILURE_LIMIT,
|
|
95
|
+
);
|
|
96
|
+
if (!shouldIsolate) {
|
|
97
|
+
return this.result(rows.length, 0, quarantined, valid.length);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let delivered = 0;
|
|
102
|
+
let isolatedFailures = 0;
|
|
103
|
+
for (const { row, event } of valid) {
|
|
104
|
+
try {
|
|
105
|
+
await this.port.publish([event]);
|
|
106
|
+
this.outbox.markDelivered([row.eventId], now);
|
|
107
|
+
delivered += 1;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.outbox.quarantine(row.eventId, now, errorText(error));
|
|
110
|
+
quarantined += 1;
|
|
111
|
+
isolatedFailures += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return this.result(
|
|
115
|
+
rows.length,
|
|
116
|
+
delivered,
|
|
117
|
+
quarantined,
|
|
118
|
+
isolatedFailures,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private result(
|
|
123
|
+
selected: number,
|
|
124
|
+
delivered: number,
|
|
125
|
+
quarantined: number,
|
|
126
|
+
failed: number,
|
|
127
|
+
): TelemetryDeliveryResult {
|
|
128
|
+
const hasPending = this.outbox.hasPending();
|
|
129
|
+
return {
|
|
130
|
+
selected,
|
|
131
|
+
delivered,
|
|
132
|
+
quarantined,
|
|
133
|
+
failed,
|
|
134
|
+
hasPending,
|
|
135
|
+
retryAfterMs: hasPending ? TELEMETRY_RETRY_DELAY_MS : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|