@oxygen-agent/cli 1.922.14 → 1.948.1

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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +18 -0
  3. package/dist/admin-primary-providers-render.js +371 -0
  4. package/dist/command-manifest.js +30 -2
  5. package/dist/functions-commands.d.ts +6 -0
  6. package/dist/functions-commands.js +56 -0
  7. package/dist/help.js +1 -0
  8. package/dist/http-client.d.ts +4 -0
  9. package/dist/http-client.js +49 -2
  10. package/dist/index.js +515 -92
  11. package/dist/ugc-commands.d.ts +6 -0
  12. package/dist/ugc-commands.js +1089 -0
  13. package/dist/visual-commands.d.ts +6 -0
  14. package/dist/visual-commands.js +57 -0
  15. package/dist/visual-render-wait.d.ts +3 -0
  16. package/dist/visual-render-wait.js +56 -0
  17. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +48 -0
  18. package/node_modules/@oxygen/shared/dist/byok-connect.js +92 -0
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -13
  20. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  21. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  22. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  23. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  24. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +2 -0
  25. package/node_modules/@oxygen/shared/dist/feature-gates.js +3 -0
  26. package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
  27. package/node_modules/@oxygen/shared/dist/index.js +10 -0
  28. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
  29. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
  30. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +3 -2
  32. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +1 -1
  33. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  34. package/node_modules/@oxygen/shared/dist/langfuse.js +185 -121
  35. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  37. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  38. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  39. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +6 -0
  40. package/node_modules/@oxygen/shared/dist/object-storage.js +5 -0
  41. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  42. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  43. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  44. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  45. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
  46. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
  47. package/node_modules/@oxygen/shared/dist/provider-balance-signal.d.ts +113 -0
  48. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +158 -0
  49. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +12 -0
  50. package/node_modules/@oxygen/shared/dist/sequence-failures.js +24 -0
  51. package/node_modules/@oxygen/shared/dist/sequences.d.ts +16 -0
  52. package/node_modules/@oxygen/shared/dist/sequences.js +54 -0
  53. package/node_modules/@oxygen/shared/dist/ugc.d.ts +133 -0
  54. package/node_modules/@oxygen/shared/dist/ugc.js +2 -0
  55. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +9 -0
  56. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +33 -0
  57. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  58. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/visual-render.d.ts +30 -0
  60. package/node_modules/@oxygen/shared/dist/visual-render.js +55 -0
  61. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +43 -0
  62. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +126 -0
  63. package/node_modules/@oxygen/shared/package.json +10 -0
  64. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  65. package/package.json +1 -1
@@ -21,11 +21,9 @@
21
21
  // * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
22
22
  // lease-reclaim replays still converge onto ONE trace. See
23
23
  // llmTraceIdForSeed.
24
- // * Observation ids can NO LONGER be chosen. v5 observation ids are W3C span
25
- // ids minted by OTel. The caller's stable id (`gen:<turn>:<n>`, `tool:…`)
26
- // is preserved as metadata.oxygen_observation_id for correlation, but it no
27
- // longer upserts: a replayed slice appends duplicate observations to the
28
- // same trace instead of overwriting them.
24
+ // * A private OTel id generator emits one real deterministic root per seed.
25
+ // Child ids identify physical observations; oxygen_observation_id preserves
26
+ // the caller's logical identity without conflating repeated executions.
29
27
  // * v5 is observations-first: correlating attributes (userId, sessionId,
30
28
  // tags) must ride EVERY observation, not just the root, or per-session cost
31
29
  // rollups miss the cost-bearing generations. They are applied through
@@ -34,13 +32,20 @@
34
32
  // * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
35
33
  // observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
36
34
  // used here.
37
- import { createHash } from "node:crypto";
35
+ import { createHash, randomBytes } from "node:crypto";
36
+ import { OXYGEN_VERSION } from "./version.js";
37
+ import { prepareLlmPayload } from "./llm-payload.js";
38
38
  import { log } from "./log.js";
39
39
  const FLUSH_TIMEOUT_MS = 5_000;
40
40
  const WARN_THROTTLE_MS = 30_000;
41
- // Defensive per-field bound, well under Langfuse's ~1 MB event cap. Copilot
42
- // transcripts max out around 150 KB; anything larger is truncated with an
43
- // explicit marker rather than risking a rejected ingestion batch.
41
+ /**
42
+ * The OTLP exporter's own deadline, in SECONDS (the @langfuse/otel option's
43
+ * unit). Deliberately ABOVE FLUSH_TIMEOUT_MS: our bound protects the caller,
44
+ * this one protects the batch, and making them equal — the SDK's 5s default —
45
+ * meant every flush that ran long lost its spans instead of finishing late.
46
+ */
47
+ const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
48
+ // Scores are API strings; unlike observation payloads they cannot use chunks.
44
49
  const MAX_JSON_FIELD_CHARS = 400_000;
45
50
  /**
46
51
  * FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
@@ -93,42 +98,13 @@ export function resolveLlmTracingEnvironment(env = process.env) {
93
98
  export function llmTraceIdForSeed(seed) {
94
99
  return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
95
100
  }
96
- /**
97
- * Deterministic synthetic parent span id for one trace.
98
- *
99
- * v5 does not let a caller choose observation ids, and the worker emits a single
100
- * run's observations from different processes and slices — so there is no real
101
- * root span id to nest under. Every observation of a run instead attaches to
102
- * this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
103
- * adapter already produced (v3 passed only `traceId`, never
104
- * `parentObservationId`, so observations were already siblings of the trace).
105
- */
101
+ /** The real root's stable W3C span id, also used across worker resumes. */
106
102
  function rootSpanIdForSeed(seed) {
107
103
  return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
108
104
  }
109
- // Bound one JSON-bearing field. Over the cap → an explicit truncation marker
110
- // (never a silently clipped payload that parses as complete).
111
- function boundJsonField(value) {
112
- if (value === undefined || value === null)
113
- return value;
114
- let serialized;
115
- try {
116
- serialized = JSON.stringify(value) ?? "";
117
- }
118
- catch {
119
- return { truncated: true, reason: "unserializable" };
120
- }
121
- if (serialized.length <= MAX_JSON_FIELD_CHARS)
122
- return value;
123
- return {
124
- truncated: true,
125
- chars: serialized.length,
126
- preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
127
- };
128
- }
129
105
  // A score's `comment` is free text (a thumbs-down reason is user-authored and
130
106
  // unbounded) and the API types it as a STRING, so it cannot take
131
- // boundJsonField's `{truncated, preview}` envelope. It gets the same
107
+ // a recoverable payload envelope. It gets the same
132
108
  // MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
133
109
  // clip" rule, with the marker counted INSIDE the cap so the bound holds.
134
110
  const COMMENT_TRUNCATION_MARKER = "…[truncated]";
@@ -142,13 +118,13 @@ function compact(body) {
142
118
  for (const [key, value] of Object.entries(body)) {
143
119
  if (value === undefined || value === null)
144
120
  continue;
145
- out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
121
+ out[key] = value;
146
122
  }
147
123
  return out;
148
124
  }
149
- function boundedNever(rejectable, warn) {
125
+ function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
150
126
  return new Promise((resolve) => {
151
- const timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
127
+ const timer = setTimeout(resolve, timeoutMs);
152
128
  timer.unref?.();
153
129
  rejectable
154
130
  .catch((error) => warn(error))
@@ -158,42 +134,98 @@ function boundedNever(rejectable, warn) {
158
134
  });
159
135
  });
160
136
  }
161
- /**
162
- * The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
163
- * a runtime with tracing disabled (the packed CLI, every test) never pays for
164
- * the OTel tree — matching the "inert unless enabled" doctrine the flag already
165
- * promises.
166
- */
137
+ /** Lazy, private, always-sampled OTel transport. Never register it globally. */
167
138
  function createOtelEmitter(env, warn) {
168
139
  let handle = null;
169
140
  const init = () => {
170
141
  handle ??= (async () => {
171
142
  try {
172
- const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
173
- import("@langfuse/otel"),
174
- import("@opentelemetry/sdk-trace-base"),
175
- import("@langfuse/tracing"),
143
+ const [{ LangfuseSpanProcessor }, { BasicTracerProvider, AlwaysOnSampler }, tracing, otel, core] = await Promise.all([
144
+ import("@langfuse/otel"), import("@opentelemetry/sdk-trace-base"),
145
+ import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
176
146
  ]);
177
147
  const processor = new LangfuseSpanProcessor({
178
148
  publicKey: env.LANGFUSE_PUBLIC_KEY,
179
149
  secretKey: env.LANGFUSE_SECRET_KEY,
180
150
  ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
181
151
  environment: resolveLlmTracingEnvironment(env),
152
+ release: env.VERCEL_GIT_COMMIT_SHA || env.OXYGEN_GIT_SHA || OXYGEN_VERSION,
153
+ timeout: LANGFUSE_EXPORT_TIMEOUT_SECONDS,
154
+ // SimpleSpanProcessor hits the exporter's concurrent-request limit
155
+ // on large chunk bursts. Batch them, starting promptly; after() drains
156
+ // the batch with a bound above the exporter's own deadline.
157
+ exportMode: "batched",
158
+ flushInterval: 1,
159
+ flushAt: 128,
160
+ });
161
+ let identity;
162
+ const provider = new BasicTracerProvider({
163
+ spanProcessors: [{
164
+ onStart: (span) => {
165
+ // Directly pass the private parent claim to the processor. A
166
+ // standalone process may have no global context manager at all;
167
+ // context.with()/propagateAttributes alone would silently lose
168
+ // the claim and user/session on every observation in that case.
169
+ const parentContext = identity && !identity.isRoot
170
+ ? core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, identity.traceId)
171
+ : otel.ROOT_CONTEXT;
172
+ processor.onStart(span, parentContext);
173
+ if (identity)
174
+ span.setAttributes(compact({
175
+ "user.id": identity.correlation.userId,
176
+ "session.id": identity.correlation.sessionId,
177
+ "langfuse.trace.name": identity.correlation.traceName,
178
+ "langfuse.trace.tags": identity.correlation.tags,
179
+ }));
180
+ },
181
+ onEnd: (span) => processor.onEnd(span),
182
+ forceFlush: () => processor.forceFlush(),
183
+ shutdown: () => processor.shutdown(),
184
+ }], sampler: new AlwaysOnSampler(),
185
+ idGenerator: {
186
+ generateTraceId: () => identity?.traceId ?? randomBytes(16).toString("hex"),
187
+ generateSpanId: () => identity?.spanId ?? randomBytes(8).toString("hex"),
188
+ },
182
189
  });
183
- // PRIVATE provider. Deliberately NOT .register()ed — see the file
184
- // header: the global provider carries the Axiom OTLP exporters, and a
185
- // processor there would receive every prompt-bearing span.
186
- //
187
- // v5's smart default span filter needs no override here: this provider
188
- // only ever creates spans through the Langfuse tracer, and
189
- // `langfuse-sdk` spans are in the default allow-list.
190
- const provider = new BasicTracerProvider({ spanProcessors: [processor] });
191
- tracing.setLangfuseTracerProvider(provider);
192
- return {
193
- processor,
194
- startObservation: tracing.startObservation,
195
- propagateAttributes: tracing.propagateAttributes,
190
+ // Only used to avoid repeatedly creating provisional roots in this
191
+ // process. A final root emission always updates the same physical id.
192
+ const roots = new Set();
193
+ const emit = (emission) => {
194
+ const traceId = llmTraceIdForSeed(emission.traceSeed);
195
+ const rootId = rootSpanIdForSeed(emission.traceSeed);
196
+ if (!emission.isRoot && !roots.has(traceId)) {
197
+ emit({
198
+ kind: "span", traceSeed: emission.traceSeed, isRoot: true,
199
+ observationId: rootId, name: emission.correlation.traceName ?? emission.name.split(".")[0] + ".run",
200
+ attributes: { version: OXYGEN_VERSION, metadata: { oxygen_trace_root: true, oxygen_trace_seed: emission.traceSeed, oxygen_capture_version: 2 } },
201
+ correlation: emission.correlation, startTime: emission.startTime, endTime: emission.startTime,
202
+ });
203
+ }
204
+ if (emission.isRoot) {
205
+ roots.add(traceId);
206
+ if (roots.size > 4096)
207
+ roots.delete(roots.values().next().value);
208
+ }
209
+ // SDK configuration is module-local, whereas Next bundles and tests
210
+ // may construct more than one private client. Select THIS provider
211
+ // immediately before the synchronous creation; never use global OTel.
212
+ tracing.setLangfuseTracerProvider(provider);
213
+ identity = { traceId, spanId: emission.observationId ?? randomBytes(8).toString("hex"), isRoot: emission.isRoot === true, correlation: emission.correlation };
214
+ try {
215
+ otel.context.with(emission.isRoot ? otel.ROOT_CONTEXT : core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, traceId), () => tracing.propagateAttributes(emission.correlation, () => {
216
+ const observation = tracing.startObservation(emission.name, emission.attributes, {
217
+ asType: emission.kind, startTime: emission.startTime,
218
+ ...(emission.isRoot ? {} : { parentSpanContext: { traceId, spanId: emission.parentObservationId ?? rootId, traceFlags: 1 } }),
219
+ });
220
+ if (emission.kind !== "event")
221
+ observation.end(emission.endTime);
222
+ }));
223
+ }
224
+ finally {
225
+ identity = undefined;
226
+ }
196
227
  };
228
+ return { processor, emit };
197
229
  }
198
230
  catch (error) {
199
231
  warn(error, { stage: "init" });
@@ -203,33 +235,7 @@ function createOtelEmitter(env, warn) {
203
235
  return handle;
204
236
  };
205
237
  return {
206
- emit: (emission) => {
207
- void init()
208
- .then((h) => {
209
- if (!h)
210
- return;
211
- const traceId = llmTraceIdForSeed(emission.traceSeed);
212
- // propagateAttributes is scope-based in v5: the observation must be
213
- // created INSIDE the callback to inherit userId/sessionId/tags.
214
- h.propagateAttributes(emission.correlation, () => {
215
- // The kind is a union, so no single overload matches it. Every
216
- // overload returns an observation extending the same base, and the
217
- // only method used here is .end() — so resolving against the span
218
- // overload is safe while the real kind is passed at runtime.
219
- const observation = h.startObservation(emission.name, emission.attributes, {
220
- asType: emission.kind,
221
- startTime: emission.startTime,
222
- parentSpanContext: {
223
- traceId,
224
- spanId: rootSpanIdForSeed(emission.traceSeed),
225
- traceFlags: 1,
226
- },
227
- });
228
- observation.end(emission.endTime);
229
- });
230
- })
231
- .catch((error) => warn(error, { stage: emission.kind }));
232
- },
238
+ emit: (emission) => { void init().then((h) => h?.emit(emission)).catch((error) => warn(error, { stage: emission.kind })); },
233
239
  flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
234
240
  shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
235
241
  };
@@ -313,7 +319,52 @@ export function createLlmTracingClient(env = process.env, options) {
313
319
  ...context,
314
320
  });
315
321
  };
316
- const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
322
+ const transport = options?.emitterImpl ?? createOtelEmitter(env, warn);
323
+ const emitter = {
324
+ ...transport,
325
+ emit: (emission) => {
326
+ const observationId = emission.observationId ?? randomBytes(8).toString("hex");
327
+ const metadata = { ...emission.attributes.metadata, oxygen_capture_version: 2 };
328
+ const attributes = { ...emission.attributes, version: OXYGEN_VERSION, metadata };
329
+ const chunkEmissions = [];
330
+ const capture = (value, field, key) => {
331
+ const prepared = prepareLlmPayload(value, key);
332
+ for (const chunk of prepared.chunks) {
333
+ chunkEmissions.push({
334
+ kind: "event", traceSeed: emission.traceSeed, name: "llm.payload_chunk", parentObservationId: observationId,
335
+ attributes: { version: OXYGEN_VERSION, input: chunk, metadata: {
336
+ oxygen_capture_version: 2, oxygen_payload_id: chunk.payload_id,
337
+ oxygen_payload_chunk_index: chunk.index, oxygen_payload_chunk_count: prepared.chunks.length,
338
+ oxygen_observation_id: `${metadata.oxygen_observation_id ?? observationId}:payload:${field}:${chunk.index}`,
339
+ } },
340
+ correlation: emission.correlation, startTime: emission.endTime ?? emission.startTime, endTime: emission.endTime ?? emission.startTime,
341
+ });
342
+ }
343
+ return prepared.value;
344
+ };
345
+ for (const key of ["input", "output", "modelParameters"]) {
346
+ if (key in attributes)
347
+ attributes[key] = capture(attributes[key], key);
348
+ }
349
+ if (typeof attributes.statusMessage === "string") {
350
+ const status = prepareLlmPayload(attributes.statusMessage);
351
+ if (typeof status.value === "string" && status.value.length <= 2000)
352
+ attributes.statusMessage = status.value;
353
+ else {
354
+ metadata.status_message = attributes.statusMessage;
355
+ attributes.statusMessage = "See metadata.status_message for full error detail";
356
+ }
357
+ }
358
+ for (const [key, value] of Object.entries(metadata))
359
+ metadata[key] = capture(value, `metadata.${key}`, key);
360
+ if (attributes.modelParameters && typeof attributes.modelParameters === "object") {
361
+ attributes.modelParameters = Object.fromEntries(Object.entries(attributes.modelParameters).map(([key, value]) => [key, typeof value === "number" || typeof value === "string" ? value : JSON.stringify(value)]));
362
+ }
363
+ transport.emit({ ...emission, observationId, attributes });
364
+ for (const chunk of chunkEmissions)
365
+ transport.emit(chunk);
366
+ },
367
+ };
317
368
  const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
318
369
  const guarded = (fn, stage) => {
319
370
  try {
@@ -343,19 +394,44 @@ export function createLlmTracingClient(env = process.env, options) {
343
394
  tags: input.tags,
344
395
  });
345
396
  };
397
+ const modelObservation = (body, kind) => guarded(() => {
398
+ emitter.emit({
399
+ kind,
400
+ traceSeed: body.traceId,
401
+ name: body.name,
402
+ attributes: compact({
403
+ input: body.input,
404
+ output: body.output,
405
+ level: body.level,
406
+ statusMessage: body.statusMessage,
407
+ model: body.model,
408
+ modelParameters: body.modelParameters,
409
+ completionStartTime: body.completionStartTime,
410
+ usageDetails: body.usageDetails,
411
+ costDetails: body.costDetails,
412
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
413
+ }),
414
+ // The cost-bearing observation: v5 session cost only rolls up when
415
+ // the generation itself carries the session.
416
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
417
+ startTime: body.startTime ?? new Date(),
418
+ ...(body.endTime ? { endTime: body.endTime } : {}),
419
+ });
420
+ }, kind);
346
421
  return {
347
422
  trace: (body) => guarded(() => {
348
423
  const startTime = body.startTime ?? new Date();
349
424
  emitter.emit({
350
425
  kind: "span",
351
426
  traceSeed: body.id,
427
+ observationId: rootSpanIdForSeed(body.id), isRoot: true,
352
428
  name: body.name,
353
429
  // Overall trace IO lives on this ROOT observation — v5 deprecates
354
430
  // trace-level input/output, so it is deliberately not set separately.
355
431
  attributes: compact({
356
432
  input: body.input,
357
433
  output: body.output,
358
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
434
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id, oxygen_trace_root: true, oxygen_trace_seed: body.id },
359
435
  }),
360
436
  correlation: correlate({
361
437
  traceName: body.name,
@@ -366,6 +442,15 @@ export function createLlmTracingClient(env = process.env, options) {
366
442
  startTime,
367
443
  endTime: body.endTime ?? startTime,
368
444
  });
445
+ if (body.metadata?.observation_scope === "execution_slice") {
446
+ const sliceStart = typeof body.metadata.slice_started_at === "string" ? new Date(body.metadata.slice_started_at) : startTime;
447
+ emitter.emit({
448
+ kind: "span", traceSeed: body.id, name: `${body.name}.slice`,
449
+ attributes: compact({ input: body.input, output: body.output, metadata: { ...body.metadata, oxygen_observation_id: `${body.id}:slice:${body.metadata.slice_id}` } }),
450
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
451
+ startTime: Number.isFinite(sliceStart.getTime()) ? sliceStart : startTime, endTime: body.endTime ?? startTime,
452
+ });
453
+ }
369
454
  }, "trace"),
370
455
  span: (body) => guarded(() => {
371
456
  emitter.emit({
@@ -384,29 +469,8 @@ export function createLlmTracingClient(env = process.env, options) {
384
469
  ...(body.endTime ? { endTime: body.endTime } : {}),
385
470
  });
386
471
  }, "span"),
387
- generation: (body) => guarded(() => {
388
- emitter.emit({
389
- kind: "generation",
390
- traceSeed: body.traceId,
391
- name: body.name,
392
- attributes: compact({
393
- input: body.input,
394
- output: body.output,
395
- level: body.level,
396
- statusMessage: body.statusMessage,
397
- model: body.model,
398
- completionStartTime: body.completionStartTime,
399
- usageDetails: body.usageDetails,
400
- costDetails: body.costDetails,
401
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
402
- }),
403
- // The cost-bearing observation: v5 session cost only rolls up when
404
- // the generation itself carries the session.
405
- correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
406
- startTime: body.startTime ?? new Date(),
407
- ...(body.endTime ? { endTime: body.endTime } : {}),
408
- });
409
- }, "generation"),
472
+ generation: (body) => modelObservation(body, "generation"),
473
+ embedding: (body) => modelObservation(body, "embedding"),
410
474
  event: (body) => guarded(() => {
411
475
  const startTime = body.startTime ?? new Date();
412
476
  emitter.emit({
@@ -435,10 +499,10 @@ export function createLlmTracingClient(env = process.env, options) {
435
499
  }
436
500
  },
437
501
  // The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
438
- // enforced here rather than inside one emitter — an emitter that throws
502
+ // enforced here (15s for serverless after()) rather than inside one emitter — an emitter that throws
439
503
  // synchronously or rejects must still not escape into product code.
440
- flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
441
- shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" })),
504
+ flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
505
+ shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
442
506
  };
443
507
  }
444
508
  // --- Process-wide singleton (both runtimes construct at most one client) ------
@@ -0,0 +1,10 @@
1
+ export type LlmPayloadChunk = {
2
+ payload_id: string;
3
+ index: number;
4
+ data: string;
5
+ };
6
+ /** Only credentials are masked: prompts, tool arguments and token counts stay. */
7
+ export declare function prepareLlmPayload(value: unknown, fieldKey?: string): {
8
+ value: unknown;
9
+ chunks: LlmPayloadChunk[];
10
+ };
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { redactSecretsInString } from "./redaction.js";
3
+ // Bound individual OTLP fields in UTF-8 bytes. Large payloads remain fully
4
+ // recoverable in child events rather than losing the end of a model response.
5
+ const INLINE_BYTES = 128 * 1024;
6
+ const CHUNK_BYTES = 64 * 1024;
7
+ const SECRET_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|secret|(?:x[-_]?)?api[-_]?key|x[-_]?auth[-_]?token|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|connection[-_]?uri|database[-_]?url)$/i;
8
+ const SECRET_QUERY = /([?&](?:token|access_token|api_key|key|signature|x-amz-signature|x-goog-signature)=)[^&#\s"]+/gi;
9
+ /** Only credentials are masked: prompts, tool arguments and token counts stay. */
10
+ export function prepareLlmPayload(value, fieldKey = "") {
11
+ if (SECRET_KEY.test(fieldKey))
12
+ value = "[REDACTED]";
13
+ if (value === undefined || value === null)
14
+ return { value, chunks: [] };
15
+ let serialized;
16
+ try {
17
+ // Tool arguments and SSE frames are often JSON encoded inside a string.
18
+ // Inspect those nested documents too; redacting only outer object keys
19
+ // would leave an API key inside tool_calls[].function.arguments intact.
20
+ const serialize = (input, nestedDepth = 0) => JSON.stringify(input, (key, item) => {
21
+ if (SECRET_KEY.test(key))
22
+ return "[REDACTED]";
23
+ if (typeof item !== "string")
24
+ return item;
25
+ let text = redactSecretsInString(item).replace(SECRET_QUERY, "$1[REDACTED]");
26
+ if (/^\s*[\[{]/.test(text)) {
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (nestedDepth >= 32)
30
+ return "[REDACTED_NESTED_JSON_DEPTH]";
31
+ text = serialize(parsed, nestedDepth + 1);
32
+ }
33
+ catch { /* ordinary prompt text, not a JSON document */ }
34
+ }
35
+ return text;
36
+ }) ?? "null";
37
+ serialized = serialize(value);
38
+ }
39
+ catch {
40
+ return { value: { truncated: true, reason: "unserializable" }, chunks: [] };
41
+ }
42
+ const bytes = Buffer.from(serialized, "utf8");
43
+ if (bytes.length <= INLINE_BYTES)
44
+ return { value: JSON.parse(serialized), chunks: [] };
45
+ const id = createHash("sha256").update(bytes).digest("hex");
46
+ const chunks = [];
47
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
48
+ chunks.push({ payload_id: id, index: chunks.length, data: bytes.subarray(offset, offset + CHUNK_BYTES).toString("base64") });
49
+ }
50
+ return {
51
+ value: { oxygen_payload: { id, encoding: "base64-json-utf8", bytes: bytes.length, chunks: chunks.length, sha256: id } },
52
+ chunks,
53
+ };
54
+ }
@@ -0,0 +1,11 @@
1
+ /** Inclusive provider counts. Keep the original provider usage on the observation's metadata. */
2
+ export type LlmProviderUsage = {
3
+ promptTokens?: number | null | undefined;
4
+ completionTokens?: number | null | undefined;
5
+ reasoningTokens?: number | null | undefined;
6
+ cachedPromptTokens?: number | null | undefined;
7
+ cacheWriteTokens?: number | null | undefined;
8
+ totalTokens?: number | null | undefined;
9
+ };
10
+ /** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
11
+ export declare function normalizeLlmUsageDetails(usage: LlmProviderUsage | null | undefined): Record<string, number> | undefined;
@@ -0,0 +1,30 @@
1
+ /** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
2
+ export function normalizeLlmUsageDetails(usage) {
3
+ if (!usage)
4
+ return undefined;
5
+ const count = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
6
+ const input = count(usage.promptTokens);
7
+ const output = count(usage.completionTokens);
8
+ const details = {};
9
+ if (input !== undefined) {
10
+ const cached = Math.min(input, count(usage.cachedPromptTokens) ?? 0);
11
+ const created = Math.min(input - cached, count(usage.cacheWriteTokens) ?? 0);
12
+ details.input = input - cached - created;
13
+ if (cached > 0)
14
+ details.input_cached_tokens = cached;
15
+ if (created > 0)
16
+ details.input_cache_creation_tokens = created;
17
+ }
18
+ if (output !== undefined) {
19
+ // Some providers return a detail count larger than its inclusive parent.
20
+ // Preserve the parent total rather than inventing extra billed tokens.
21
+ const reasoning = Math.min(output, count(usage.reasoningTokens) ?? 0);
22
+ details.output = output - reasoning;
23
+ if (reasoning > 0)
24
+ details.output_reasoning_tokens = reasoning;
25
+ }
26
+ const total = input !== undefined && output !== undefined ? input + output : count(usage.totalTokens);
27
+ if (total !== undefined)
28
+ details.total = total;
29
+ return Object.keys(details).length > 0 ? details : undefined;
30
+ }
@@ -1,4 +1,10 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
1
2
  export declare function isObjectStorageConfigured(): boolean;
3
+ /** Server-only storage adapter seam. Callers own tenant and object-policy checks. */
4
+ export declare function resolveObjectStorageClient(): {
5
+ client: S3Client;
6
+ bucket: string;
7
+ };
2
8
  export declare function buildImportObjectKey(input: {
3
9
  organizationId: string;
4
10
  fileName?: string | null;
@@ -77,6 +77,11 @@ function resolveClient() {
77
77
  }
78
78
  // Keys are namespaced by org so a tenant can only ever be handed (and the
79
79
  // enqueue route only accepts) keys under its own prefix.
80
+ /** Server-only storage adapter seam. Callers own tenant and object-policy checks. */
81
+ export function resolveObjectStorageClient() {
82
+ const { client, config } = resolveClient();
83
+ return { client, bucket: config.bucket };
84
+ }
80
85
  export function buildImportObjectKey(input) {
81
86
  const safeName = sanitizeFileName(input.fileName) || "import";
82
87
  return `imports/${input.organizationId}/${randomUUID()}/${safeName}`;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Product-analytics core — the TRANSPORT-FREE half of PostHog emission.
3
+ *
4
+ * Two runtimes emit product events: the web/API runtime (`apps/web/src/lib/
5
+ * product-analytics.ts`, `posthog-node` on Vercel) and the Fly worker
6
+ * (`apps/worker/src/product-analytics.ts`, `posthog-node` batching). What they
7
+ * must agree on lives here, once: property sanitization, the distinct-id
8
+ * chain, the person-profile posture, org grouping, and the runtime dimensions
9
+ * every event carries. Two copies of that logic drifted in the past — the
10
+ * worker would have minted a throwaway person per request while the web side
11
+ * did not — and PostHog cannot join what the emitters disagree about.
12
+ *
13
+ * Deliberately NO `posthog-node`, no fetch, no I/O: `packages/shared` is
14
+ * vendored into the published `@oxygen-agent/cli` tarball, so every runtime
15
+ * dependency added here lands on every customer's machine
16
+ * (`scripts/cli-package-dependencies.mjs`, the langfuse-class guard). The
17
+ * capture message is a plain object structurally compatible with
18
+ * `posthog-node`'s `EventMessage`; each runtime owns its own client.
19
+ */
20
+ export declare const PRODUCT_ANALYTICS_MAX_PROPERTY_KEY_LENGTH = 80;
21
+ export declare const PRODUCT_ANALYTICS_MAX_PROPERTY_STRING_LENGTH = 500;
22
+ /**
23
+ * Byte ceiling for a structured (object/array) property value.
24
+ *
25
+ * Structured values pass through UNTOUCHED — see `sanitizeStructuredValue` — so
26
+ * this is the only thing standing between a careless caller and an unbounded
27
+ * ingest payload. Set well above `MCP_PAYLOAD_LIMITS.maxSerializedBytes` (8 KiB,
28
+ * in apps/web `analytics/mcp-analytics.ts`) so it can never re-cut a payload
29
+ * that module already bounded and marked; it exists for the callers that bound
30
+ * nothing.
31
+ */
32
+ export declare const PRODUCT_ANALYTICS_MAX_STRUCTURED_PROPERTY_BYTES = 32768;
33
+ /**
34
+ * A JSON value PostHog can index as a structured property.
35
+ *
36
+ * PostHog's reserved MCP-Analytics properties (`$mcp_parameters`,
37
+ * `$mcp_response`, `$mcp_listed_tool_names`) are objects and arrays by vendor
38
+ * contract, so a scalar-only property type made the entire `$mcp_*` surface
39
+ * untransmittable: a `.slice(...)` on an array is a `TypeError`, and that throw
40
+ * escaped `trackProductEvent` — losing the whole event, not just the property.
41
+ */
42
+ export type ProductAnalyticsJsonValue = string | number | boolean | null | readonly ProductAnalyticsJsonValue[] | {
43
+ readonly [key: string]: ProductAnalyticsJsonValue;
44
+ };
45
+ export type ProductAnalyticsValue = ProductAnalyticsJsonValue | undefined;
46
+ /** What `sanitizeProductAnalyticsProperties` is allowed to hand the SDK. */
47
+ export type ProductAnalyticsSanitizedValue = string | number | boolean | null | ProductAnalyticsJsonValue;
48
+ export type ProductAnalyticsProperties = Record<string, ProductAnalyticsValue>;
49
+ export type ProductAnalyticsEventInput = {
50
+ event: string;
51
+ distinctId?: string | null | undefined;
52
+ organizationId?: string | null | undefined;
53
+ userId?: string | null | undefined;
54
+ properties?: ProductAnalyticsProperties;
55
+ /**
56
+ * Ingestion id. PostHog dedupes on it, so a revenue event replayed from a
57
+ * Stripe webhook redelivery — or a run outcome re-read after a worker
58
+ * restart — lands once instead of double-counting. Supply a value derived
59
+ * from the source fact (the KPI event key, the run id), never a random one,
60
+ * or replay stops being deterministic.
61
+ */
62
+ uuid?: string | null | undefined;
63
+ /** When the fact happened, if that is not "now" (webhook replay, backfill, run settle). */
64
+ timestamp?: Date | null | undefined;
65
+ };
66
+ /**
67
+ * The message shape both `posthog-node` capture paths accept. Declared here so
68
+ * the builder needs no `posthog-node` import; the web/worker clients pass it
69
+ * straight to `capture` / `captureImmediate`.
70
+ */
71
+ export type ProductAnalyticsCaptureMessage = {
72
+ distinctId: string;
73
+ event: string;
74
+ properties: Record<string, ProductAnalyticsSanitizedValue>;
75
+ groups?: Record<string, string>;
76
+ uuid?: string;
77
+ timestamp?: Date;
78
+ };
79
+ /**
80
+ * What the emitting runtime knows about itself. `environment` is the
81
+ * PROD/MAIN/DEV/LOCAL dimension every insight filters on
82
+ * (`resolveProductAnalyticsEnvironmentFromEnv`); `runtimeProperties` are the
83
+ * runtime-specific facts (Vercel env/sha/region on the web, Fly app/region on
84
+ * the worker) and are set BEFORE the caller's properties so a caller can
85
+ * override them but never lose them by accident.
86
+ */
87
+ export type ProductAnalyticsRuntimeContext = {
88
+ environment: string;
89
+ oxygenVersion: string;
90
+ runtimeProperties?: ProductAnalyticsProperties;
91
+ };
92
+ export declare function sanitizeProductAnalyticsProperties(properties: ProductAnalyticsProperties): Record<string, ProductAnalyticsSanitizedValue>;
93
+ export declare function normalizeProductAnalyticsIdentifier(value: ProductAnalyticsValue): string | null;
94
+ /**
95
+ * Build the capture message every runtime sends. Returns null when no distinct
96
+ * id can be derived — an event with no subject is dropped, never invented.
97
+ */
98
+ export declare function buildProductAnalyticsCaptureMessage(input: ProductAnalyticsEventInput, context: ProductAnalyticsRuntimeContext): ProductAnalyticsCaptureMessage | null;