@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.
- package/README.md +1 -1
- package/dist/command-manifest.js +24 -2
- package/dist/help.js +1 -0
- package/dist/index.js +342 -54
- package/dist/ugc-commands.js +353 -12
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
- package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +53 -5
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/index.js +6 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
- package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +21 -1
- 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/workflows/dist/graph/lint.js +22 -0
- 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
|
|
6
|
-
* (control-DB `organizations.iconDomain
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
16
|
-
* armed table-webhook auto-run
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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),
|
|
56
|
-
*
|
|
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
|
-
//
|
|
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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
95
|
+
/** Never rejects; bounded at 5s on workers, 15s in serverless after(). */
|
|
94
96
|
flush(): Promise<void>;
|
|
95
|
-
/** Flush + stop
|
|
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
|
-
// *
|
|
25
|
-
// ids
|
|
26
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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] =
|
|
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,
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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) =>
|
|
405
|
-
|
|
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
|
+
};
|