@oxygen-agent/cli 1.894.0 → 1.917.5

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 +11 -3
  3. package/dist/index.js +348 -43
  4. package/node_modules/@oxygen/formula/dist/expression.d.ts +21 -0
  5. package/node_modules/@oxygen/formula/dist/expression.js +42 -1
  6. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +1 -1
  7. package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -1
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +30 -7
  9. package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +1 -0
  10. package/node_modules/@oxygen/shared/dist/copilot-errors.js +9 -0
  11. package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +169 -0
  12. package/node_modules/@oxygen/shared/dist/copilot-plan.js +476 -0
  13. package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
  14. package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
  15. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
  16. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
  17. package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
  18. package/node_modules/@oxygen/shared/dist/index.js +3 -0
  19. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +93 -5
  20. package/node_modules/@oxygen/shared/dist/langfuse.js +326 -42
  21. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +1 -1
  22. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +16 -5
  23. package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
  24. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
  25. package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
  26. package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
  27. package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
  28. package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
  29. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +23 -2
  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/shared/package.json +10 -0
  33. package/package.json +5 -2
@@ -24,6 +24,7 @@ export * from "./crm-activity-events.js";
24
24
  export * from "./column-types.js";
25
25
  export * from "./copilot-errors.js";
26
26
  export * from "./copilot-journeys.js";
27
+ export * from "./copilot-plan.js";
27
28
  export * from "./credit-guidance.js";
28
29
  export * from "./directory.js";
29
30
  export * from "./email-tracking-token.js";
@@ -49,6 +50,7 @@ export * from "./member-columns.js";
49
50
  export * from "./microsoft-consent-url.js";
50
51
  export * from "./networks.js";
51
52
  export * from "./person-name.js";
53
+ export * from "./product-doctrine.js";
52
54
  export * from "./recipes.js";
53
55
  export * from "./sequence-template.js";
54
56
  export * from "./sequence-crm-events.js";
@@ -86,6 +88,7 @@ export * from "./postgres-text.js";
86
88
  export * from "./tags.js";
87
89
  export * from "./telemetry.js";
88
90
  export * from "./tenant-database-secret.js";
91
+ export * from "./egress-transport-readiness.js";
89
92
  export * from "./timing.js";
90
93
  export * from "./type-guards.js";
91
94
  export * from "./worker-failures-queue.js";
@@ -1,4 +1,3 @@
1
- import { Langfuse } from "langfuse";
2
1
  type EnvMap = Record<string, string | undefined>;
3
2
  export type LlmObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
4
3
  export type LlmTraceBody = {
@@ -10,6 +9,13 @@ export type LlmTraceBody = {
10
9
  output?: unknown;
11
10
  metadata?: Record<string, unknown>;
12
11
  tags?: string[];
12
+ /**
13
+ * Root-observation span. v3 emitted a durationless trace object; v5's root IS
14
+ * an observation, so a root emitted once at completion should carry the real
15
+ * turn/run window. Defaults to "now" for both when omitted.
16
+ */
17
+ startTime?: Date;
18
+ endTime?: Date;
13
19
  };
14
20
  export type LlmSpanBody = {
15
21
  id: string;
@@ -22,6 +28,14 @@ export type LlmSpanBody = {
22
28
  endTime?: Date;
23
29
  level?: LlmObservationLevel;
24
30
  statusMessage?: string | null;
31
+ /**
32
+ * v5 observations-first correlation. Pass the SAME sessionId/userId the trace
33
+ * carries: in v5 these live on every observation, and session-level cost only
34
+ * adds up if the cost-bearing generations carry the session too. Optional so
35
+ * an emitter that has no session still traces.
36
+ */
37
+ sessionId?: string | null;
38
+ userId?: string | null;
25
39
  };
26
40
  export type LlmGenerationBody = LlmSpanBody & {
27
41
  model?: string | null;
@@ -36,12 +50,46 @@ export type LlmEventBody = {
36
50
  input?: unknown;
37
51
  metadata?: Record<string, unknown>;
38
52
  startTime?: Date;
53
+ /** See LlmSpanBody.sessionId. */
54
+ sessionId?: string | null;
55
+ userId?: string | null;
56
+ };
57
+ /**
58
+ * An eval/annotation attached to a trace (ADR 0014 files these as exactly what
59
+ * the LLM store exists to enable). Narrower than the API's CreateScoreRequest on
60
+ * purpose: `traceId` is REQUIRED (a score orphaned from its trace is unreadable
61
+ * in the UI), `value` is numeric-only, and `dataType` drops "CATEGORICAL"
62
+ * because that variant requires a *string* value. Widening later stays additive.
63
+ */
64
+ export type LlmScoreBody = {
65
+ /** Deterministic id upserts; omit for a new score each call. */
66
+ id?: string;
67
+ traceId: string;
68
+ name: string;
69
+ value: number;
70
+ dataType?: "BOOLEAN" | "NUMERIC";
71
+ comment?: string;
39
72
  };
40
73
  export type LlmTracingClient = {
41
74
  trace(body: LlmTraceBody): void;
42
75
  span(body: LlmSpanBody): void;
43
76
  generation(body: LlmGenerationBody): void;
44
77
  event(body: LlmEventBody): void;
78
+ /**
79
+ * Attach a score to an existing trace.
80
+ *
81
+ * ASYNC, unlike the four above, because it is not the same transport. Spans go
82
+ * through the OTel processor, which batches and is drained by `flush()`; a
83
+ * score has no span, so it is one HTTP call to the scores API. Awaiting it is
84
+ * what keeps it alive on a serverless function that freezes the moment the
85
+ * handler returns — there is nothing for `flush()` to drain on its behalf.
86
+ *
87
+ * Never rejects: resolves `true` when the score was accepted, `false` when it
88
+ * was not (transport down, tracing misconfigured, API refusal). Fail-open like
89
+ * every other path here — telemetry must not break the product write it
90
+ * describes.
91
+ */
92
+ score(body: LlmScoreBody): Promise<boolean>;
45
93
  /** Never rejects; bounded at ~5s. */
46
94
  flush(): Promise<void>;
47
95
  /** Flush + stop background timers. Never rejects; bounded at ~5s. */
@@ -53,16 +101,56 @@ export type LlmTracingClient = {
53
101
  */
54
102
  export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
55
103
  export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
56
- type LangfuseLike = Pick<Langfuse, "trace" | "span" | "generation" | "event" | "flushAsync" | "shutdownAsync"> & {
57
- on?: (event: string, listener: (...args: unknown[]) => void) => void;
104
+ /**
105
+ * Deterministic Langfuse trace id for an external seed (copilot turn id, agent
106
+ * run id, AI-column run id).
107
+ *
108
+ * Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
109
+ * UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
110
+ * a Promise (it uses WebCrypto), and every cross-link site here writes the id on
111
+ * a hot, synchronous path: the tenant ledger's `turn_started` payload and the
112
+ * Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
113
+ * tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
114
+ * the SDK's own implementation so the two can never drift.
115
+ */
116
+ export declare function llmTraceIdForSeed(seed: string): string;
117
+ export type LlmEmissionKind = "span" | "generation" | "event";
118
+ /** v5 correlating attributes, propagated onto the emitted observation. */
119
+ export type LlmCorrelation = {
120
+ traceName?: string;
121
+ userId?: string;
122
+ sessionId?: string;
123
+ tags?: string[];
124
+ };
125
+ export type LlmEmission = {
126
+ kind: LlmEmissionKind;
127
+ /** External seed (turn/run id) — hashed into the W3C trace id. */
128
+ traceSeed: string;
129
+ name: string;
130
+ /** Observation-level attributes, already compacted/bounded. */
131
+ attributes: Record<string, unknown>;
132
+ /** Correlating attributes applied via propagateAttributes(). */
133
+ correlation: LlmCorrelation;
134
+ startTime: Date;
135
+ endTime?: Date;
136
+ };
137
+ export type LlmEmitter = {
138
+ emit(emission: LlmEmission): void;
139
+ flush(): Promise<void>;
140
+ shutdown(): Promise<void>;
141
+ };
142
+ export type LlmScorer = {
143
+ /** Resolves true when the score was accepted. Never rejects. */
144
+ score(body: LlmScoreBody): Promise<boolean>;
58
145
  };
59
146
  /**
60
147
  * Construct a fail-open Langfuse client, or `null` when tracing is disabled or
61
148
  * misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
62
- * this direct factory exists for tests (inject `langfuseImpl`).
149
+ * this direct factory exists for tests (inject `emitterImpl`).
63
150
  */
64
151
  export declare function createLlmTracingClient(env?: EnvMap, options?: {
65
- langfuseImpl?: LangfuseLike;
152
+ emitterImpl?: LlmEmitter;
153
+ scorerImpl?: LlmScorer;
66
154
  }): LlmTracingClient | null;
67
155
  export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
68
156
  /** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
@@ -1,18 +1,40 @@
1
1
  // LLM-observability transport (ADR 0014): Langfuse is the ONE sanctioned store
2
2
  // for full prompt/completion/tool-IO payloads. Axiom stays metadata-only (log.ts
3
3
  // redaction drops prompt/input/output-named fields BY DESIGN — that boundary is
4
- // unchanged), and PostHog stays sanitized product analytics. This module
5
- // deliberately rides the official `langfuse` SDK's own batched ingestion instead
6
- // of the OTel pipeline so prompt-bearing telemetry can never reach the Axiom
7
- // OTLP exporters by construction.
4
+ // unchanged), and PostHog stays sanitized product analytics.
5
+ //
6
+ // Langfuse SDK v5 is OpenTelemetry-based, so the v3-era "never touch OTel"
7
+ // isolation is now bought a different way: this module builds its OWN
8
+ // TracerProvider, hands it to Langfuse via setLangfuseTracerProvider(), and
9
+ // NEVER calls .register(). That matters — @vercel/otel (web) and NodeSDK
10
+ // (worker) install Axiom OTLP exporters on the GLOBAL provider, and every
11
+ // processor on a provider sees every span it creates. Registering globally
12
+ // would therefore ship prompts, completions and tool IO straight into
13
+ // oxygen-traces. The private provider is the load-bearing part of this file.
8
14
  //
9
15
  // Fail-open doctrine: tracing must never fail or stall product work. Every
10
16
  // method swallows internally (worst case: one throttled metadata-only warn via
11
17
  // log()), `flush()`/`shutdown()` never reject and are time-bounded, and a
12
- // missing flag/key yields `null` (callers no-op). Deterministic observation ids
13
- // are the caller's job — Langfuse upserts by id, which is what makes worker
14
- // lease-reclaim replays converge instead of duplicating.
15
- import { Langfuse } from "langfuse";
18
+ // missing flag/key yields `null` (callers no-op).
19
+ //
20
+ // v5 semantics (CHANGED from v3):
21
+ // * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
22
+ // lease-reclaim replays still converge onto ONE trace. See
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.
29
+ // * v5 is observations-first: correlating attributes (userId, sessionId,
30
+ // tags) must ride EVERY observation, not just the root, or per-session cost
31
+ // rollups miss the cost-bearing generations. They are applied through
32
+ // propagateAttributes() around each emission — which is why the span/
33
+ // generation/event bodies carry sessionId/userId at all.
34
+ // * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
35
+ // observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
36
+ // used here.
37
+ import { createHash } from "node:crypto";
16
38
  import { log } from "./log.js";
17
39
  const FLUSH_TIMEOUT_MS = 5_000;
18
40
  const WARN_THROTTLE_MS = 30_000;
@@ -56,6 +78,34 @@ export function resolveLlmTracingEnvironment(env = process.env) {
56
78
  return flyEnv === "production" ? "production" : "development";
57
79
  return "development";
58
80
  }
81
+ /**
82
+ * Deterministic Langfuse trace id for an external seed (copilot turn id, agent
83
+ * run id, AI-column run id).
84
+ *
85
+ * Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
86
+ * UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
87
+ * a Promise (it uses WebCrypto), and every cross-link site here writes the id on
88
+ * a hot, synchronous path: the tenant ledger's `turn_started` payload and the
89
+ * Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
90
+ * tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
91
+ * the SDK's own implementation so the two can never drift.
92
+ */
93
+ export function llmTraceIdForSeed(seed) {
94
+ return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
95
+ }
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
+ */
106
+ function rootSpanIdForSeed(seed) {
107
+ return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
108
+ }
59
109
  // Bound one JSON-bearing field. Over the cap → an explicit truncation marker
60
110
  // (never a silently clipped payload that parses as complete).
61
111
  function boundJsonField(value) {
@@ -76,10 +126,21 @@ function boundJsonField(value) {
76
126
  preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
77
127
  };
78
128
  }
129
+ // A score's `comment` is free text (a thumbs-down reason is user-authored and
130
+ // unbounded) and the API types it as a STRING, so it cannot take
131
+ // boundJsonField's `{truncated, preview}` envelope. It gets the same
132
+ // MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
133
+ // clip" rule, with the marker counted INSIDE the cap so the bound holds.
134
+ const COMMENT_TRUNCATION_MARKER = "…[truncated]";
135
+ function boundComment(comment) {
136
+ if (comment.length <= MAX_JSON_FIELD_CHARS)
137
+ return comment;
138
+ return comment.slice(0, MAX_JSON_FIELD_CHARS - COMMENT_TRUNCATION_MARKER.length) + COMMENT_TRUNCATION_MARKER;
139
+ }
79
140
  function compact(body) {
80
141
  const out = {};
81
142
  for (const [key, value] of Object.entries(body)) {
82
- if (value === undefined)
143
+ if (value === undefined || value === null)
83
144
  continue;
84
145
  out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
85
146
  }
@@ -97,13 +158,148 @@ function boundedNever(rejectable, warn) {
97
158
  });
98
159
  });
99
160
  }
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
+ */
167
+ function createOtelEmitter(env, warn) {
168
+ let handle = null;
169
+ const init = () => {
170
+ handle ??= (async () => {
171
+ try {
172
+ const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
173
+ import("@langfuse/otel"),
174
+ import("@opentelemetry/sdk-trace-base"),
175
+ import("@langfuse/tracing"),
176
+ ]);
177
+ const processor = new LangfuseSpanProcessor({
178
+ publicKey: env.LANGFUSE_PUBLIC_KEY,
179
+ secretKey: env.LANGFUSE_SECRET_KEY,
180
+ ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
181
+ environment: resolveLlmTracingEnvironment(env),
182
+ });
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,
196
+ };
197
+ }
198
+ catch (error) {
199
+ warn(error, { stage: "init" });
200
+ return null;
201
+ }
202
+ })();
203
+ return handle;
204
+ };
205
+ 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
+ },
233
+ flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
234
+ shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
235
+ };
236
+ }
237
+ /**
238
+ * The real scorer: one authenticated POST to the Langfuse scores API.
239
+ *
240
+ * `@langfuse/core` is imported LAZILY on first score for the same reason the
241
+ * OTel tree is — a runtime with tracing disabled (the packed CLI, every test)
242
+ * never pays for it.
243
+ *
244
+ * The API client's `environment` option is its BASE URL, not the Langfuse
245
+ * environment tag; the tag is the `environment` FIELD on the score body, and it
246
+ * is resolved from the same helper the span processor uses so a score lands in
247
+ * the same Langfuse environment as the trace it scores. OXYGEN always sets
248
+ * LANGFUSE_BASE_URL (the project is US-region and the EU host 401s these keys);
249
+ * the fallback is the SDK's documented default, which `@langfuse/core` itself
250
+ * does not supply.
251
+ */
252
+ function createApiScorer(env, warn) {
253
+ let handle = null;
254
+ const init = () => {
255
+ handle ??= (async () => {
256
+ try {
257
+ const { LangfuseAPIClient } = await import("@langfuse/core");
258
+ const client = new LangfuseAPIClient({
259
+ environment: env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com",
260
+ username: env.LANGFUSE_PUBLIC_KEY,
261
+ password: env.LANGFUSE_SECRET_KEY,
262
+ });
263
+ return client.scores;
264
+ }
265
+ catch (error) {
266
+ warn(error, { stage: "score_init" });
267
+ return null;
268
+ }
269
+ })();
270
+ return handle;
271
+ };
272
+ return {
273
+ score: async (body) => {
274
+ try {
275
+ const api = await init();
276
+ if (!api)
277
+ return false;
278
+ await api.create(compact({
279
+ id: body.id,
280
+ traceId: body.traceId,
281
+ name: body.name,
282
+ value: body.value,
283
+ dataType: body.dataType,
284
+ comment: typeof body.comment === "string" ? boundComment(body.comment) : undefined,
285
+ environment: resolveLlmTracingEnvironment(env),
286
+ }));
287
+ return true;
288
+ }
289
+ catch (error) {
290
+ warn(error, { stage: "score" });
291
+ return false;
292
+ }
293
+ },
294
+ };
295
+ }
100
296
  /**
101
297
  * Construct a fail-open Langfuse client, or `null` when tracing is disabled or
102
298
  * misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
103
- * this direct factory exists for tests (inject `langfuseImpl`).
299
+ * this direct factory exists for tests (inject `emitterImpl`).
104
300
  */
105
301
  export function createLlmTracingClient(env = process.env, options) {
106
- if (!options?.langfuseImpl && !isLlmTracingEnabled(env))
302
+ if (!options?.emitterImpl && !isLlmTracingEnabled(env))
107
303
  return null;
108
304
  let lastWarnAtMs = 0;
109
305
  const warn = (error, context) => {
@@ -117,30 +313,8 @@ export function createLlmTracingClient(env = process.env, options) {
117
313
  ...context,
118
314
  });
119
315
  };
120
- let sdk;
121
- try {
122
- sdk =
123
- options?.langfuseImpl ??
124
- new Langfuse({
125
- publicKey: env.LANGFUSE_PUBLIC_KEY,
126
- secretKey: env.LANGFUSE_SECRET_KEY,
127
- ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
128
- environment: resolveLlmTracingEnvironment(env),
129
- sdkIntegration: "oxygen",
130
- });
131
- }
132
- catch (error) {
133
- warn(error, { stage: "construct" });
134
- return null;
135
- }
136
- // The SDK surfaces async ingest failures on its emitter; unheard, they are
137
- // unhandled rejections. Route them into the throttled warn.
138
- try {
139
- sdk.on?.("error", (error) => warn(error, { stage: "ingest" }));
140
- }
141
- catch {
142
- // an emitter-less test double is fine
143
- }
316
+ const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
317
+ const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
144
318
  const guarded = (fn, stage) => {
145
319
  try {
146
320
  fn();
@@ -149,13 +323,122 @@ export function createLlmTracingClient(env = process.env, options) {
149
323
  warn(error, { stage });
150
324
  }
151
325
  };
326
+ // propagateAttributes rejects non-string ids and anything over 200 chars, and
327
+ // drops the whole attribute with a warning rather than truncating. Normalize
328
+ // here so a stray null/oversized id degrades to "absent", never to a dropped
329
+ // correlation on every observation of the run.
330
+ const correlate = (input) => {
331
+ const bounded = (value) => {
332
+ if (typeof value !== "string")
333
+ return undefined;
334
+ const trimmed = value.trim();
335
+ if (trimmed === "" || trimmed.length > 200)
336
+ return undefined;
337
+ return trimmed;
338
+ };
339
+ return compact({
340
+ traceName: input.traceName,
341
+ sessionId: bounded(input.sessionId),
342
+ userId: bounded(input.userId),
343
+ tags: input.tags,
344
+ });
345
+ };
152
346
  return {
153
- trace: (body) => guarded(() => void sdk.trace(compact(body)), "trace"),
154
- span: (body) => guarded(() => void sdk.span(compact(body)), "span"),
155
- generation: (body) => guarded(() => void sdk.generation(compact(body)), "generation"),
156
- event: (body) => guarded(() => void sdk.event(compact(body)), "event"),
157
- flush: () => boundedNever(sdk.flushAsync(), (error) => warn(error, { stage: "flush" })),
158
- shutdown: () => boundedNever(sdk.shutdownAsync(), (error) => warn(error, { stage: "shutdown" })),
347
+ trace: (body) => guarded(() => {
348
+ const startTime = body.startTime ?? new Date();
349
+ emitter.emit({
350
+ kind: "span",
351
+ traceSeed: body.id,
352
+ name: body.name,
353
+ // Overall trace IO lives on this ROOT observation — v5 deprecates
354
+ // trace-level input/output, so it is deliberately not set separately.
355
+ attributes: compact({
356
+ input: body.input,
357
+ output: body.output,
358
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
359
+ }),
360
+ correlation: correlate({
361
+ traceName: body.name,
362
+ sessionId: body.sessionId,
363
+ userId: body.userId,
364
+ ...(body.tags ? { tags: body.tags } : {}),
365
+ }),
366
+ startTime,
367
+ endTime: body.endTime ?? startTime,
368
+ });
369
+ }, "trace"),
370
+ span: (body) => guarded(() => {
371
+ emitter.emit({
372
+ kind: "span",
373
+ traceSeed: body.traceId,
374
+ name: body.name,
375
+ attributes: compact({
376
+ input: body.input,
377
+ output: body.output,
378
+ level: body.level,
379
+ statusMessage: body.statusMessage,
380
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
381
+ }),
382
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
383
+ startTime: body.startTime ?? new Date(),
384
+ ...(body.endTime ? { endTime: body.endTime } : {}),
385
+ });
386
+ }, "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"),
410
+ event: (body) => guarded(() => {
411
+ const startTime = body.startTime ?? new Date();
412
+ emitter.emit({
413
+ kind: "event",
414
+ traceSeed: body.traceId,
415
+ name: body.name,
416
+ attributes: compact({
417
+ input: body.input,
418
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
419
+ }),
420
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
421
+ startTime,
422
+ endTime: startTime,
423
+ });
424
+ }, "event"),
425
+ // Already fail-open inside the scorer; the extra catch is here so that a
426
+ // scorer which throws SYNCHRONOUSLY (a substituted one in a test, a future
427
+ // implementation) still cannot escape into a product write.
428
+ score: async (body) => {
429
+ try {
430
+ return await scorer.score(body);
431
+ }
432
+ catch (error) {
433
+ warn(error, { stage: "score" });
434
+ return false;
435
+ }
436
+ },
437
+ // 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
439
+ // 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" })),
159
442
  };
160
443
  }
161
444
  // --- Process-wide singleton (both runtimes construct at most one client) ------
@@ -188,7 +471,8 @@ export function flushLlmTracing(env = process.env) {
188
471
  // then call scheduleLlmTracingFlush() and the flush runs post-response instead
189
472
  // of adding latency inside the request. Off-web (worker, tests) there is no
190
473
  // scheduler and the flush degrades to fire-and-forget — the worker's cycle-end
191
- // awaited flush + SDK interval flush are the durability guarantee there.
474
+ // awaited flush + the processor's own interval flush are the durability
475
+ // guarantee there.
192
476
  //
193
477
  // The registration lives on globalThis, not in a module-local: Next.js gives
194
478
  // instrumentation.ts and each route handler their own copy of this module, so a
@@ -1,5 +1,5 @@
1
1
  /**
2
- * How a LinkedIn quota denial is read by the background jobs that hit it.
2
+ * How a LinkedIn/WhatsApp quota denial is read by the background jobs that hit it.
3
3
  *
4
4
  * The denial itself is raised by the chokepoint in
5
5
  * packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * How a LinkedIn quota denial is read by the background jobs that hit it.
2
+ * How a LinkedIn/WhatsApp quota denial is read by the background jobs that hit it.
3
3
  *
4
4
  * The denial itself is raised by the chokepoint in
5
5
  * packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
@@ -16,10 +16,21 @@
16
16
  */
17
17
  import { OxygenError } from "./cli-result.js";
18
18
  import { isRecord } from "./type-guards.js";
19
- // The two codes a denial is raised with. Either is a "come back later" signal,
20
- // not a broken caller: a daily cap / closed active window that reopens on its own
21
- // clock, or an account the status webhook will reactivate.
22
- const QUOTA_DENIED_CODES = new Set(["linkedin_rate_limited", "linkedin_account_unavailable"]);
19
+ // The codes a denial is raised with. Each is a "come back later" signal, not a
20
+ // broken caller: a daily cap / closed active window that reopens on its own clock,
21
+ // or an account the status webhook will reactivate.
22
+ //
23
+ // The WhatsApp mirrors are here because the inbox backstop is shared across
24
+ // networks: a WhatsApp daily-limit denial was reaching it, missing this set, and
25
+ // so was logged as a failure AND never parked -- the same hot re-deny loop the
26
+ // LinkedIn codes were added to stop. Both WhatsApp denials carry `resets_at`, so
27
+ // the park lands on the real reset rather than the fallback below.
28
+ const QUOTA_DENIED_CODES = new Set([
29
+ "linkedin_rate_limited",
30
+ "linkedin_account_unavailable",
31
+ "whatsapp_rate_limited",
32
+ "whatsapp_account_unavailable",
33
+ ]);
23
34
  /** Park length when a denial carries no usable `resets_at` hint. */
24
35
  const QUOTA_FALLBACK_BACKOFF_MS = 60 * 60 * 1000;
25
36
  /** Was this thrown error the quota chokepoint refusing the call? */