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