@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.
- package/README.md +1 -1
- package/dist/command-manifest.js +11 -3
- package/dist/index.js +348 -43
- package/node_modules/@oxygen/formula/dist/expression.d.ts +21 -0
- package/node_modules/@oxygen/formula/dist/expression.js +42 -1
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +1 -1
- package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +30 -7
- package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +9 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +169 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.js +476 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +93 -5
- package/node_modules/@oxygen/shared/dist/langfuse.js +326 -42
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +16 -5
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
- package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +23 -2
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +10 -0
- 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
|
-
|
|
57
|
-
|
|
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 `
|
|
149
|
+
* this direct factory exists for tests (inject `emitterImpl`).
|
|
63
150
|
*/
|
|
64
151
|
export declare function createLlmTracingClient(env?: EnvMap, options?: {
|
|
65
|
-
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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).
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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 `
|
|
299
|
+
* this direct factory exists for tests (inject `emitterImpl`).
|
|
104
300
|
*/
|
|
105
301
|
export function createLlmTracingClient(env = process.env, options) {
|
|
106
|
-
if (!options?.
|
|
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
|
-
|
|
121
|
-
|
|
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(() =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 +
|
|
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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
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? */
|