@oxygen-agent/cli 1.936.1 → 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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +24 -2
  3. package/dist/help.js +1 -0
  4. package/dist/index.js +342 -54
  5. package/dist/ugc-commands.js +353 -12
  6. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
  7. package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +53 -5
  9. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  10. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  11. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  12. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  13. package/node_modules/@oxygen/shared/dist/index.d.ts +6 -0
  14. package/node_modules/@oxygen/shared/dist/index.js +6 -0
  15. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
  16. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
  17. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  18. package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
  19. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  21. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  22. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  23. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  24. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  25. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  26. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  27. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
  28. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
  29. package/node_modules/@oxygen/shared/dist/ugc.d.ts +21 -1
  30. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  33. package/package.json +1 -1
@@ -2,28 +2,35 @@
2
2
  * Knowledge bootstrap — the once-per-workspace, automatic company research pass.
3
3
  *
4
4
  * WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
5
- * OWN company from the domain we already resolved at org creation
6
- * (control-DB `organizations.iconDomain`, `iconStatus = 'ok'`), then fills the typed
7
- * company profile (`ox_context.company_profile`) and writes one cited wiki page.
8
- * A founder who signs up should not face an empty Knowledge Graph and have to type
9
- * their own positioning back at us; every grounded AI action downstream (message
10
- * drafts, AI columns, agent runs) reads that profile, so an empty one degrades the
11
- * whole product's first hour.
5
+ * OWN company from the domain the creator typed when the workspace was created
6
+ * (control-DB `organizations.iconDomain`), then fills the typed company profile
7
+ * (`ox_context.company_profile`) and writes one cited wiki page. A founder who
8
+ * signs up should not face an empty Knowledge Graph and have to type their own
9
+ * positioning back at us; every grounded AI action downstream (message drafts, AI
10
+ * columns, agent runs) reads that profile, so an empty one degrades the whole
11
+ * product's first hour.
12
12
  *
13
13
  * WHY THIS IS NOT A VIOLATION OF THE PAID-ACTIONS RULE. `CLAUDE.md` says never run
14
14
  * paid provider actions unless the user explicitly asks. This slice is a deliberate,
15
- * founder-approved exception: signup IS the standing authorization, the same way an
16
- * armed table-webhook auto-run configuration is scoped standing permission for the
17
- * columns it queues. The exception is defensible ONLY because every one of the
18
- * following properties holds. They are load-bearing — do not drop one for
15
+ * founder-approved exception: naming the company at workspace creation IS the
16
+ * standing authorization, the same way an armed table-webhook auto-run
17
+ * configuration is scoped standing permission for the columns it queues — and
18
+ * since 2026-09-11 the pass is OXYGEN-funded, so it never draws down the
19
+ * customer's balance at all. The exception is defensible ONLY because every one of
20
+ * the following properties holds. They are load-bearing — do not drop one for
19
21
  * convenience, and if you remove one, the exception no longer stands:
20
22
  *
21
23
  * 1. CAPPED — `KNOWLEDGE_BOOTSTRAP_MAX_CREDITS` is a hard ceiling on the whole
22
24
  * pass, with `KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS` a tighter
23
- * sub-ceiling on the provider (external-money) half.
24
- * 2. CONSENTED — it runs only for a workspace whose own domain we already derived
25
- * at signup; a personal-email signup is `skipped_personal_email`
26
- * upstream and never reaches here, so we never research a stranger.
25
+ * sub-ceiling on the provider (external-money) half. The cap bounds
26
+ * OXYGEN's own money now, and it is the same number.
27
+ * 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
28
+ * website into the REQUIRED field at workspace creation; that
29
+ * entry is recorded as `knowledge_bootstrap_consent` (source
30
+ * `workspace_creation`) and read FAIL-CLOSED, so a workspace that
31
+ * predates the field is never researched and a personal-email
32
+ * signup with no website never reaches the worker. We never
33
+ * research a stranger.
27
34
  * 3. JOURNALLED — the marker below records status, timing, run id, credits and the
28
35
  * domain in `knowledge_state.watermarks`; the URLs read land as
29
36
  * immutable `page_sources` rows plus an `ingest` knowledge_log
@@ -36,6 +43,14 @@
36
43
  * ordinary revisioned wiki writes a human can revert.
37
44
  * 6. EXACTLY-ONCE — the marker is claimed with a conditional UPDATE, so N worker
38
45
  * replicas polling one tenant produce one run, never N runs.
46
+ * 7. OXYGEN-FUNDED — the automatic pass meters managed credits through the ordinary
47
+ * tool and AI-column lanes (so the cap, the marker and the run row
48
+ * all stay honest), and the worker then grants exactly the credits
49
+ * it used back to the workspace as an idempotent `bonus` ledger
50
+ * row keyed by the run (`KNOWLEDGE_BOOTSTRAP_COVERAGE_IDEMPOTENCY_PREFIX`).
51
+ * The customer's balance nets to zero and both rows are visible in
52
+ * their ledger. Only the AUTOMATIC pass is covered: a requested
53
+ * `--live --max-credits` re-run is the customer's own paid action.
39
54
  *
40
55
  * CAP ARITHMETIC (prices re-read from packages/integrations/src/cogs-rates.ts —
41
56
  * confirm them there rather than trusting this comment after a re-pricing):
@@ -52,8 +67,9 @@
52
67
  * overall ceiling.
53
68
  * Hard cap 300 cr — 85 cr nominal plus room for two retried synthesis
54
69
  * passes. 3% of the 10,000-credit signup grant
55
- * (FREE_SIGNUP_GRANT_CREDITS, ./billing.ts), so the
56
- * pass can never eat a founder's trial.
70
+ * (FREE_SIGNUP_GRANT_CREDITS, ./billing.ts), which is
71
+ * the headroom the balance gate needs even though the
72
+ * spend is granted back.
57
73
  *
58
74
  * The obvious implementation — firecrawl.map + 3x firecrawl.scrape — costs
59
75
  * 4 x 40 = 160 cr for the SAME job, because Firecrawl bills per page. exa.contents
@@ -132,12 +148,14 @@ export function isKnowledgeBootstrapStatus(value) {
132
148
  KNOWLEDGE_BOOTSTRAP_STATUSES.includes(value));
133
149
  }
134
150
  // ---------------------------------------------------------------------------
135
- // Consent (property 2 of the exception above, made explicit)
151
+ // Authorization (property 2 of the exception above, made explicit)
136
152
  // ---------------------------------------------------------------------------
137
153
  /**
138
- * Control-DB `organizations.metadata` key carrying the workspace's recorded consent
139
- * to the automatic research pass. Written once at signup by the setup surface; read
140
- * by the worker cycle before anything is spent.
154
+ * Control-DB `organizations.metadata` key carrying the workspace's recorded
155
+ * authorization for the automatic research pass. Written once, when the creator
156
+ * names the company website at workspace creation (`source: "workspace_creation"`;
157
+ * rows written by the retired `/setup` checkbox carry `source: "signup"` and stay
158
+ * valid); read by the worker cycle before anything is spent.
141
159
  *
142
160
  * It lives in shared, not in either caller, so the writer and the reader cannot
143
161
  * drift onto two different key names — a silent drift there would either deny every
@@ -208,6 +226,14 @@ export const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linkedin_url";
208
226
  * dead and the workspace is recoverable through the product.
209
227
  */
210
228
  export const KNOWLEDGE_BOOTSTRAP_STUCK_AFTER_MS = 60 * 60 * 1000;
229
+ /**
230
+ * Idempotency-key prefix for the ledger row that hands an automatic pass's
231
+ * credits back to the workspace (property 7 above). The key is
232
+ * `${prefix}:${run_id}`, so one pass is covered exactly once no matter how many
233
+ * times the worker revisits the outcome, and a `--force` re-run — a different
234
+ * run — is covered only if it was itself automatic.
235
+ */
236
+ export const KNOWLEDGE_BOOTSTRAP_COVERAGE_IDEMPOTENCY_PREFIX = "knowledge_bootstrap_coverage";
211
237
  // ---------------------------------------------------------------------------
212
238
  // Write targets (what a bootstrap actually changes)
213
239
  // ---------------------------------------------------------------------------
@@ -39,6 +39,7 @@ export type LlmSpanBody = {
39
39
  };
40
40
  export type LlmGenerationBody = LlmSpanBody & {
41
41
  model?: string | null;
42
+ modelParameters?: Record<string, unknown>;
42
43
  completionStartTime?: Date | null;
43
44
  usageDetails?: Record<string, number>;
44
45
  costDetails?: Record<string, number>;
@@ -74,6 +75,7 @@ export type LlmTracingClient = {
74
75
  trace(body: LlmTraceBody): void;
75
76
  span(body: LlmSpanBody): void;
76
77
  generation(body: LlmGenerationBody): void;
78
+ embedding(body: LlmGenerationBody): void;
77
79
  event(body: LlmEventBody): void;
78
80
  /**
79
81
  * Attach a score to an existing trace.
@@ -90,9 +92,9 @@ export type LlmTracingClient = {
90
92
  * describes.
91
93
  */
92
94
  score(body: LlmScoreBody): Promise<boolean>;
93
- /** Never rejects; bounded at ~5s. */
95
+ /** Never rejects; bounded at 5s on workers, 15s in serverless after(). */
94
96
  flush(): Promise<void>;
95
- /** Flush + stop background timers. Never rejects; bounded at ~5s. */
97
+ /** Flush + stop timers. Same worker/serverless bound as flush(). */
96
98
  shutdown(): Promise<void>;
97
99
  };
98
100
  /**
@@ -114,7 +116,7 @@ export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
114
116
  * the SDK's own implementation so the two can never drift.
115
117
  */
116
118
  export declare function llmTraceIdForSeed(seed: string): string;
117
- export type LlmEmissionKind = "span" | "generation" | "event";
119
+ export type LlmEmissionKind = "span" | "generation" | "embedding" | "event";
118
120
  /** v5 correlating attributes, propagated onto the emitted observation. */
119
121
  export type LlmCorrelation = {
120
122
  traceName?: string;
@@ -124,6 +126,9 @@ export type LlmCorrelation = {
124
126
  };
125
127
  export type LlmEmission = {
126
128
  kind: LlmEmissionKind;
129
+ observationId?: string;
130
+ parentObservationId?: string;
131
+ isRoot?: boolean;
127
132
  /** External seed (turn/run id) — hashed into the W3C trace id. */
128
133
  traceSeed: string;
129
134
  name: string;
@@ -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,7 +32,9 @@
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;
@@ -45,9 +45,7 @@ const WARN_THROTTLE_MS = 30_000;
45
45
  * meant every flush that ran long lost its spans instead of finishing late.
46
46
  */
47
47
  const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
48
- // Defensive per-field bound, well under Langfuse's ~1 MB event cap. Copilot
49
- // transcripts max out around 150 KB; anything larger is truncated with an
50
- // explicit marker rather than risking a rejected ingestion batch.
48
+ // Scores are API strings; unlike observation payloads they cannot use chunks.
51
49
  const MAX_JSON_FIELD_CHARS = 400_000;
52
50
  /**
53
51
  * FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
@@ -100,42 +98,13 @@ export function resolveLlmTracingEnvironment(env = process.env) {
100
98
  export function llmTraceIdForSeed(seed) {
101
99
  return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
102
100
  }
103
- /**
104
- * Deterministic synthetic parent span id for one trace.
105
- *
106
- * v5 does not let a caller choose observation ids, and the worker emits a single
107
- * run's observations from different processes and slices — so there is no real
108
- * root span id to nest under. Every observation of a run instead attaches to
109
- * this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
110
- * adapter already produced (v3 passed only `traceId`, never
111
- * `parentObservationId`, so observations were already siblings of the trace).
112
- */
101
+ /** The real root's stable W3C span id, also used across worker resumes. */
113
102
  function rootSpanIdForSeed(seed) {
114
103
  return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
115
104
  }
116
- // Bound one JSON-bearing field. Over the cap → an explicit truncation marker
117
- // (never a silently clipped payload that parses as complete).
118
- function boundJsonField(value) {
119
- if (value === undefined || value === null)
120
- return value;
121
- let serialized;
122
- try {
123
- serialized = JSON.stringify(value) ?? "";
124
- }
125
- catch {
126
- return { truncated: true, reason: "unserializable" };
127
- }
128
- if (serialized.length <= MAX_JSON_FIELD_CHARS)
129
- return value;
130
- return {
131
- truncated: true,
132
- chars: serialized.length,
133
- preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
134
- };
135
- }
136
105
  // A score's `comment` is free text (a thumbs-down reason is user-authored and
137
106
  // unbounded) and the API types it as a STRING, so it cannot take
138
- // boundJsonField's `{truncated, preview}` envelope. It gets the same
107
+ // a recoverable payload envelope. It gets the same
139
108
  // MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
140
109
  // clip" rule, with the marker counted INSIDE the cap so the bound holds.
141
110
  const COMMENT_TRUNCATION_MARKER = "…[truncated]";
@@ -149,13 +118,13 @@ function compact(body) {
149
118
  for (const [key, value] of Object.entries(body)) {
150
119
  if (value === undefined || value === null)
151
120
  continue;
152
- out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
121
+ out[key] = value;
153
122
  }
154
123
  return out;
155
124
  }
156
- function boundedNever(rejectable, warn) {
125
+ function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
157
126
  return new Promise((resolve) => {
158
- const timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
127
+ const timer = setTimeout(resolve, timeoutMs);
159
128
  timer.unref?.();
160
129
  rejectable
161
130
  .catch((error) => warn(error))
@@ -165,52 +134,98 @@ function boundedNever(rejectable, warn) {
165
134
  });
166
135
  });
167
136
  }
168
- /**
169
- * The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
170
- * a runtime with tracing disabled (the packed CLI, every test) never pays for
171
- * the OTel tree — matching the "inert unless enabled" doctrine the flag already
172
- * promises.
173
- */
137
+ /** Lazy, private, always-sampled OTel transport. Never register it globally. */
174
138
  function createOtelEmitter(env, warn) {
175
139
  let handle = null;
176
140
  const init = () => {
177
141
  handle ??= (async () => {
178
142
  try {
179
- const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
180
- import("@langfuse/otel"),
181
- import("@opentelemetry/sdk-trace-base"),
182
- 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"),
183
146
  ]);
184
147
  const processor = new LangfuseSpanProcessor({
185
148
  publicKey: env.LANGFUSE_PUBLIC_KEY,
186
149
  secretKey: env.LANGFUSE_SECRET_KEY,
187
150
  ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
188
151
  environment: resolveLlmTracingEnvironment(env),
189
- // SECONDS, and the OTLP POST's own deadline. The SDK default is 5,
190
- // which is exactly FLUSH_TIMEOUT_MS — zero headroom, so a batch that
191
- // needed 6s was guaranteed to die on the transport and be dropped:
192
- // 37 `llm_tracing.ingest_failed` warns over 30 days to 2026-09-09,
193
- // every one stage='flush' / 'Request timed out', against 275,970
194
- // traces Langfuse accepted in the same window. 10s gives the POST
195
- // room without touching FLUSH_TIMEOUT_MS — boundedNever still
196
- // releases the caller at 5s, so a slow Langfuse can never hold a
197
- // worker tick.
152
+ release: env.VERCEL_GIT_COMMIT_SHA || env.OXYGEN_GIT_SHA || OXYGEN_VERSION,
198
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,
199
160
  });
200
- // PRIVATE provider. Deliberately NOT .register()ed — see the file
201
- // header: the global provider carries the Axiom OTLP exporters, and a
202
- // processor there would receive every prompt-bearing span.
203
- //
204
- // v5's smart default span filter needs no override here: this provider
205
- // only ever creates spans through the Langfuse tracer, and
206
- // `langfuse-sdk` spans are in the default allow-list.
207
- const provider = new BasicTracerProvider({ spanProcessors: [processor] });
208
- tracing.setLangfuseTracerProvider(provider);
209
- return {
210
- processor,
211
- startObservation: tracing.startObservation,
212
- propagateAttributes: tracing.propagateAttributes,
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
+ },
189
+ });
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
+ }
213
227
  };
228
+ return { processor, emit };
214
229
  }
215
230
  catch (error) {
216
231
  warn(error, { stage: "init" });
@@ -220,33 +235,7 @@ function createOtelEmitter(env, warn) {
220
235
  return handle;
221
236
  };
222
237
  return {
223
- emit: (emission) => {
224
- void init()
225
- .then((h) => {
226
- if (!h)
227
- return;
228
- const traceId = llmTraceIdForSeed(emission.traceSeed);
229
- // propagateAttributes is scope-based in v5: the observation must be
230
- // created INSIDE the callback to inherit userId/sessionId/tags.
231
- h.propagateAttributes(emission.correlation, () => {
232
- // The kind is a union, so no single overload matches it. Every
233
- // overload returns an observation extending the same base, and the
234
- // only method used here is .end() — so resolving against the span
235
- // overload is safe while the real kind is passed at runtime.
236
- const observation = h.startObservation(emission.name, emission.attributes, {
237
- asType: emission.kind,
238
- startTime: emission.startTime,
239
- parentSpanContext: {
240
- traceId,
241
- spanId: rootSpanIdForSeed(emission.traceSeed),
242
- traceFlags: 1,
243
- },
244
- });
245
- observation.end(emission.endTime);
246
- });
247
- })
248
- .catch((error) => warn(error, { stage: emission.kind }));
249
- },
238
+ emit: (emission) => { void init().then((h) => h?.emit(emission)).catch((error) => warn(error, { stage: emission.kind })); },
250
239
  flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
251
240
  shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
252
241
  };
@@ -330,7 +319,52 @@ export function createLlmTracingClient(env = process.env, options) {
330
319
  ...context,
331
320
  });
332
321
  };
333
- 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
+ };
334
368
  const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
335
369
  const guarded = (fn, stage) => {
336
370
  try {
@@ -360,19 +394,44 @@ export function createLlmTracingClient(env = process.env, options) {
360
394
  tags: input.tags,
361
395
  });
362
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);
363
421
  return {
364
422
  trace: (body) => guarded(() => {
365
423
  const startTime = body.startTime ?? new Date();
366
424
  emitter.emit({
367
425
  kind: "span",
368
426
  traceSeed: body.id,
427
+ observationId: rootSpanIdForSeed(body.id), isRoot: true,
369
428
  name: body.name,
370
429
  // Overall trace IO lives on this ROOT observation — v5 deprecates
371
430
  // trace-level input/output, so it is deliberately not set separately.
372
431
  attributes: compact({
373
432
  input: body.input,
374
433
  output: body.output,
375
- 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 },
376
435
  }),
377
436
  correlation: correlate({
378
437
  traceName: body.name,
@@ -383,6 +442,15 @@ export function createLlmTracingClient(env = process.env, options) {
383
442
  startTime,
384
443
  endTime: body.endTime ?? startTime,
385
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
+ }
386
454
  }, "trace"),
387
455
  span: (body) => guarded(() => {
388
456
  emitter.emit({
@@ -401,29 +469,8 @@ export function createLlmTracingClient(env = process.env, options) {
401
469
  ...(body.endTime ? { endTime: body.endTime } : {}),
402
470
  });
403
471
  }, "span"),
404
- generation: (body) => guarded(() => {
405
- emitter.emit({
406
- kind: "generation",
407
- traceSeed: body.traceId,
408
- name: body.name,
409
- attributes: compact({
410
- input: body.input,
411
- output: body.output,
412
- level: body.level,
413
- statusMessage: body.statusMessage,
414
- model: body.model,
415
- completionStartTime: body.completionStartTime,
416
- usageDetails: body.usageDetails,
417
- costDetails: body.costDetails,
418
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
419
- }),
420
- // The cost-bearing observation: v5 session cost only rolls up when
421
- // the generation itself carries the session.
422
- correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
423
- startTime: body.startTime ?? new Date(),
424
- ...(body.endTime ? { endTime: body.endTime } : {}),
425
- });
426
- }, "generation"),
472
+ generation: (body) => modelObservation(body, "generation"),
473
+ embedding: (body) => modelObservation(body, "embedding"),
427
474
  event: (body) => guarded(() => {
428
475
  const startTime = body.startTime ?? new Date();
429
476
  emitter.emit({
@@ -452,10 +499,10 @@ export function createLlmTracingClient(env = process.env, options) {
452
499
  }
453
500
  },
454
501
  // The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
455
- // 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
456
503
  // synchronously or rejects must still not escape into product code.
457
- flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
458
- 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),
459
506
  };
460
507
  }
461
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
+ };