@oxygen-agent/cli 1.1003.12 → 1.1010.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +1 -1
  2. package/dist/column-decision-options.d.ts +20 -0
  3. package/dist/column-decision-options.js +54 -0
  4. package/dist/command-manifest.js +15 -2
  5. package/dist/functions-commands.js +11 -11
  6. package/dist/index.js +1222 -159
  7. package/dist/search-ai-filter-notice.d.ts +17 -0
  8. package/dist/search-ai-filter-notice.js +38 -0
  9. package/dist/skills.js +34 -10
  10. package/dist/util.d.ts +9 -0
  11. package/dist/util.js +14 -0
  12. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  13. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  14. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  15. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  16. package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
  17. package/node_modules/@oxygen/shared/dist/billing.js +45 -0
  18. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
  21. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  22. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  23. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  24. package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
  25. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  26. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  27. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
  28. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
  29. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  30. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  31. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  32. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  33. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  34. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  35. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
  36. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +2 -0
  41. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  42. package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
  43. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  46. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  47. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  49. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  50. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  51. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  53. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  54. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  55. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  56. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  57. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  58. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  59. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  60. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  61. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
  62. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
  63. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
  64. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
  65. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  66. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  67. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  68. package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
  69. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  70. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  71. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  72. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  73. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  74. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  75. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  76. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  77. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  78. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  79. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  80. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  81. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  82. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  83. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  84. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  85. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  86. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  87. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  88. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  89. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  90. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  91. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  92. package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
  93. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  94. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  95. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  96. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  97. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  98. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  99. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  100. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  101. package/node_modules/@oxygen/shared/package.json +9 -0
  102. package/package.json +2 -1
@@ -36,6 +36,8 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
36
36
  import { OXYGEN_VERSION } from "./version.js";
37
37
  import { prepareLlmPayload } from "./llm-payload.js";
38
38
  import { log } from "./log.js";
39
+ import { llmPromptLabel, registeredLlmPrompt } from "./llm-prompts.js";
40
+ import { activeTelemetryTraceId } from "./telemetry.js";
39
41
  const FLUSH_TIMEOUT_MS = 5_000;
40
42
  const WARN_THROTTLE_MS = 30_000;
41
43
  /**
@@ -99,6 +101,15 @@ export function resolveLlmTracingEnvironment(env = process.env) {
99
101
  * the web app nor the worker.
100
102
  */
101
103
  export function resolveLlmTracingServiceName(env = process.env) {
104
+ const deployEnv = env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase();
105
+ if (deployEnv === "selfhost-dev" || deployEnv === "selfhost-prod" || deployEnv === "selfhost-shadow") {
106
+ if (env.OXYGEN_PROCESS_ROLE === "worker") {
107
+ return deployEnv === "selfhost-prod" ? "oxygen-worker" : "oxygen-worker-dev";
108
+ }
109
+ if (env.OXYGEN_PROCESS_ROLE === "web" || env.OXYGEN_SERVICE_NAME === "oxygen-web")
110
+ return "oxygen-web";
111
+ return "oxygen-local";
112
+ }
102
113
  const flyApp = env.FLY_APP_NAME?.trim() || env.FLY_APP?.trim();
103
114
  if (flyApp)
104
115
  return flyApp.includes("-dev") ? "oxygen-worker-dev" : "oxygen-worker";
@@ -128,6 +139,9 @@ export function llmTraceIdForSeed(seed) {
128
139
  function rootSpanIdForSeed(seed) {
129
140
  return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
130
141
  }
142
+ function observationSpanId(parts) {
143
+ return createHash("sha256").update(JSON.stringify(parts), "utf8").digest("hex").slice(0, 16);
144
+ }
131
145
  // A score's `comment` is free text (a thumbs-down reason is user-authored and
132
146
  // unbounded) and the API types it as a STRING, so it cannot take
133
147
  // a recoverable payload envelope. It gets the same
@@ -148,9 +162,57 @@ function compact(body) {
148
162
  }
149
163
  return out;
150
164
  }
165
+ // --- Export summary ----------------------------------------------------------
166
+ // ingest_failed is throttled per process, so it is a floor on failures and says
167
+ // nothing about the healthy volume beside it. This rollup gives the ratio: how
168
+ // many spans Langfuse acknowledged, how many batches it refused, and how many
169
+ // observations never reached the exporter at all. Counts only; never content.
170
+ const EXPORT_SUMMARY_INTERVAL_MS = 60_000;
171
+ export function createLlmExportSummary(now = Date.now) {
172
+ let exported = 0;
173
+ let failed = 0;
174
+ let dropped = 0;
175
+ let windowStartedAt = now();
176
+ const report = () => {
177
+ const at = now();
178
+ if (exported + failed + dropped > 0) {
179
+ log("info", "llm_tracing.export_summary", {
180
+ provider: "langfuse",
181
+ exported,
182
+ failed,
183
+ dropped,
184
+ window_seconds: Math.max(0, Math.round((at - windowStartedAt) / 1000)),
185
+ });
186
+ }
187
+ exported = 0;
188
+ failed = 0;
189
+ dropped = 0;
190
+ windowStartedAt = at;
191
+ };
192
+ const add = (count) => (Number.isFinite(count) && count > 0 ? Math.floor(count) : 0);
193
+ return {
194
+ exported: (count) => { exported += add(count); },
195
+ failed: (count) => { failed += add(count); },
196
+ dropped: (count) => { dropped += add(count); },
197
+ maybeReport: () => { if (now() - windowStartedAt >= EXPORT_SUMMARY_INTERVAL_MS)
198
+ report(); },
199
+ reportNow: report,
200
+ };
201
+ }
202
+ /**
203
+ * A thrown value's class name is the only part of it that may leave the
204
+ * private store: messages from payload preparation can quote the payload.
205
+ */
206
+ function sanitizedErrorReason(error) {
207
+ const name = error instanceof Error ? error.name : typeof error;
208
+ return /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) ? name : "unknown";
209
+ }
151
210
  function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
152
211
  return new Promise((resolve) => {
153
- const timer = setTimeout(resolve, timeoutMs);
212
+ const timer = setTimeout(() => {
213
+ warn(new Error("langfuse_flush_deadline_exceeded"));
214
+ resolve();
215
+ }, timeoutMs);
154
216
  timer.unref?.();
155
217
  rejectable
156
218
  .catch((error) => warn(error))
@@ -161,17 +223,133 @@ function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
161
223
  });
162
224
  }
163
225
  /** Lazy, private, always-sampled OTel transport. Never register it globally. */
164
- function createOtelEmitter(env, warn) {
226
+ function createOtelEmitter(env, warn, summary) {
165
227
  let handle = null;
166
228
  const init = () => {
167
229
  handle ??= (async () => {
168
230
  try {
169
- const [{ LangfuseSpanProcessor }, { BasicTracerProvider, AlwaysOnSampler }, { resourceFromAttributes }, tracing, otel, core] = await Promise.all([
231
+ const [{ LangfuseSpanProcessor }, { BasicTracerProvider, AlwaysOnSampler }, { resourceFromAttributes }, tracing, otel, core, { JsonTraceSerializer }] = await Promise.all([
170
232
  import("@langfuse/otel"), import("@opentelemetry/sdk-trace-base"),
171
233
  import("@opentelemetry/resources"),
172
234
  import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
235
+ import("@opentelemetry/otlp-transformer"),
173
236
  ]);
237
+ const exportUrl = `${(env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com").replace(/\/+$/, "")}/api/public/otel/v1/traces`;
238
+ const exportHeaders = {
239
+ "Content-Type": "application/json",
240
+ Authorization: `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`,
241
+ "x-langfuse-sdk-name": "javascript",
242
+ "x-langfuse-sdk-version": core.LANGFUSE_SDK_VERSION,
243
+ "x-langfuse-public-key": env.LANGFUSE_PUBLIC_KEY,
244
+ };
245
+ const pendingExports = new Set();
246
+ // The upstream HTTP exporter logs raw partial-success/malformed response
247
+ // bodies through GLOBAL diagnostics before its callback runs. Keep the
248
+ // supported OTLP JSON serializer, but own delivery and response handling
249
+ // inside this private provider. Never mutate the global diagnostic logger.
250
+ const send = async (spans) => {
251
+ const encoded = JsonTraceSerializer.serializeRequest(spans);
252
+ if (!encoded)
253
+ throw new Error("serialization_failed");
254
+ const body = new TextDecoder().decode(encoded);
255
+ const signal = AbortSignal.timeout(LANGFUSE_EXPORT_TIMEOUT_SECONDS * 1000);
256
+ let response;
257
+ for (let attempt = 0; attempt < 3; attempt++) {
258
+ try {
259
+ response = await fetch(exportUrl, { method: "POST", headers: exportHeaders, body, signal, redirect: "error" });
260
+ }
261
+ catch {
262
+ if (signal.aborted)
263
+ throw new Error("deadline_exceeded");
264
+ if (attempt === 2)
265
+ throw new Error("network_failed");
266
+ await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
267
+ continue;
268
+ }
269
+ if (![429, 502, 503, 504].includes(response.status) || attempt === 2)
270
+ break;
271
+ // A retryable rejection has no useful private response to retain.
272
+ await response.body?.cancel().catch(() => { });
273
+ await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
274
+ }
275
+ if (!response?.ok) {
276
+ await response?.body?.cancel().catch(() => { });
277
+ throw new Error("http_rejected");
278
+ }
279
+ const chunks = [];
280
+ let bytes = 0;
281
+ const reader = response.body?.getReader();
282
+ if (reader) {
283
+ try {
284
+ for (;;) {
285
+ const next = await reader.read();
286
+ if (next.done)
287
+ break;
288
+ bytes += next.value.byteLength;
289
+ // OTLP acknowledgements are tiny; an echoed payload must never
290
+ // become an unbounded allocation or reach any diagnostic logger.
291
+ if (bytes > 64 * 1024)
292
+ throw new Error("response_too_large");
293
+ chunks.push(next.value);
294
+ }
295
+ }
296
+ catch (error) {
297
+ await reader.cancel().catch(() => { });
298
+ throw new Error(error instanceof Error && error.message === "response_too_large" ? "response_too_large" : "response_read_failed");
299
+ }
300
+ finally {
301
+ reader.releaseLock();
302
+ }
303
+ }
304
+ let acknowledgement;
305
+ try {
306
+ acknowledgement = bytes ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
307
+ }
308
+ catch {
309
+ throw new Error("response_invalid");
310
+ }
311
+ if (!acknowledgement || typeof acknowledgement !== "object" || Array.isArray(acknowledgement))
312
+ throw new Error("response_invalid");
313
+ const envelope = acknowledgement;
314
+ const partial = envelope.partialSuccess ?? envelope.partial_success;
315
+ if (partial !== undefined && partial !== null) {
316
+ if (typeof partial !== "object" || Array.isArray(partial))
317
+ throw new Error("response_invalid");
318
+ const rawRejected = partial.rejectedSpans ?? partial.rejected_spans ?? 0;
319
+ if ((typeof rawRejected !== "number" && typeof rawRejected !== "string") || !/^\d+$/.test(String(rawRejected)))
320
+ throw new Error("response_invalid");
321
+ if (Number(rawRejected) > 0)
322
+ throw new Error("partial_rejection");
323
+ }
324
+ };
325
+ const privateExporter = {
326
+ export: (spans, done) => {
327
+ const pending = send(spans).then(() => {
328
+ // Complete the batch first: a logging fault must never leave the
329
+ // processor waiting out its own export timeout.
330
+ done({ code: 0 });
331
+ summary.exported(spans.length);
332
+ summary.maybeReport();
333
+ }, (error) => {
334
+ const failures = new Set(["serialization_failed", "deadline_exceeded", "network_failed", "http_rejected", "response_too_large", "response_read_failed", "response_invalid", "partial_rejection"]);
335
+ const failureKind = error instanceof Error && failures.has(error.message) ? error.message : "transport_failed";
336
+ // BSP forwards failures to the global OTel error handler. It only
337
+ // receives our constant classification, never the server body.
338
+ done({ code: 1, error: new Error("langfuse_delivery_failed") });
339
+ summary.failed(spans.length);
340
+ summary.maybeReport();
341
+ warn(error, { stage: "export", observation_count: spans.length, failure_kind: failureKind });
342
+ });
343
+ pendingExports.add(pending);
344
+ // Even an unexpected SDK callback failure must not create a rejected
345
+ // fire-and-forget promise in the product process.
346
+ void pending.then(() => pendingExports.delete(pending), () => pendingExports.delete(pending));
347
+ },
348
+ shutdown: async () => { await Promise.allSettled([...pendingExports]); },
349
+ forceFlush: async () => { await Promise.allSettled([...pendingExports]); },
350
+ };
174
351
  const processor = new LangfuseSpanProcessor({
352
+ exporter: privateExporter,
175
353
  publicKey: env.LANGFUSE_PUBLIC_KEY,
176
354
  secretKey: env.LANGFUSE_SECRET_KEY,
177
355
  ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
@@ -267,7 +445,19 @@ function createOtelEmitter(env, warn) {
267
445
  return handle;
268
446
  };
269
447
  return {
270
- emit: (emission) => { void init().then((h) => h?.emit(emission)).catch((error) => warn(error, { stage: emission.kind })); },
448
+ emit: (emission) => {
449
+ void init().then((h) => {
450
+ // init already warned once; every later emission is lost the same way.
451
+ // Report from here too: a process whose transport never came up has
452
+ // no exports to carry the window, and that is when dropped matters.
453
+ if (!h) {
454
+ summary.dropped(1);
455
+ summary.maybeReport();
456
+ return;
457
+ }
458
+ h.emit(emission);
459
+ }).catch((error) => { summary.dropped(1); summary.maybeReport(); warn(error, { stage: emission.kind }); });
460
+ },
271
461
  flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
272
462
  shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
273
463
  };
@@ -347,6 +537,126 @@ function createApiScorer(env, warn) {
347
537
  },
348
538
  };
349
539
  }
540
+ const NOOP_PROMPT_RESOLVER = { versionFor: () => undefined };
541
+ const PROMPT_FOUND_TTL_MS = 24 * 60 * 60 * 1000;
542
+ const PROMPT_MISSING_TTL_MS = 10 * 60 * 1000;
543
+ const PROMPT_ERROR_TTL_MS = 60 * 1000;
544
+ const PROMPT_CACHE_LIMIT = 512;
545
+ function promptMetadata(prompt) {
546
+ return prompt
547
+ ? { oxygen_prompt_name: prompt.name, oxygen_prompt_hash: prompt.hash, oxygen_prompt_registered: prompt.registered }
548
+ : {};
549
+ }
550
+ /**
551
+ * Only a deployed dev process talking to the self-hosted instance may create
552
+ * prompt versions. Production and shadow data never do (the environment label
553
+ * is "production" there, or the host is Langfuse Cloud), and a local process
554
+ * would publish whatever uncommitted template its checkout holds.
555
+ */
556
+ function mayPublishMissingPrompts(env, baseUrl) {
557
+ if (resolveLlmTracingEnvironment(env) !== "development")
558
+ return false;
559
+ if (resolveLlmTracingServiceName(env) === "oxygen-local")
560
+ return false;
561
+ if (env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase() !== "selfhost-dev")
562
+ return false;
563
+ let host;
564
+ try {
565
+ host = new URL(baseUrl).hostname.toLowerCase();
566
+ }
567
+ catch {
568
+ return false;
569
+ }
570
+ return host !== "langfuse.com" && !host.endsWith(".langfuse.com");
571
+ }
572
+ export function createApiPromptResolver(env, warn, fetchImpl = fetch, now = Date.now) {
573
+ const baseUrl = (env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com").replace(/\/+$/, "");
574
+ const authorization = `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`;
575
+ const cache = new Map();
576
+ const inflight = new Set();
577
+ const publishAttempted = new Set();
578
+ const publishes = mayPublishMissingPrompts(env, baseUrl);
579
+ // One attempt per template per process; any failure falls back to metadata only.
580
+ const publish = async (key, prompt) => {
581
+ const definition = publishes && !publishAttempted.has(key) ? registeredLlmPrompt(prompt) : undefined;
582
+ if (!definition)
583
+ return undefined;
584
+ publishAttempted.add(key);
585
+ const label = llmPromptLabel(prompt.hash);
586
+ try {
587
+ const response = await fetchImpl(`${baseUrl}/api/public/v2/prompts`, {
588
+ method: "POST",
589
+ headers: { Authorization: authorization, "Content-Type": "application/json" },
590
+ body: JSON.stringify({
591
+ type: "text", name: definition.name, prompt: definition.template, labels: [label],
592
+ tags: ["oxygen", "code-owned"], commitMessage: `oxygen code template ${prompt.hash} (runtime publish)`,
593
+ }),
594
+ signal: AbortSignal.timeout(5_000),
595
+ redirect: "error",
596
+ });
597
+ if (!response.ok) {
598
+ await response.body?.cancel().catch(() => { });
599
+ warn(new Error("langfuse_prompt_link_failed"), { stage: "prompt_publish", status: response.status });
600
+ return undefined;
601
+ }
602
+ const body = await response.json();
603
+ const labels = Array.isArray(body.labels) ? body.labels : [];
604
+ return typeof body.version === "number" && Number.isInteger(body.version) && labels.includes(label) ? body.version : undefined;
605
+ }
606
+ catch {
607
+ warn(new Error("langfuse_prompt_link_failed"), { stage: "prompt_publish" });
608
+ return undefined;
609
+ }
610
+ };
611
+ const lookup = async (key, prompt) => {
612
+ const label = llmPromptLabel(prompt.hash);
613
+ let entry = { version: undefined, expiresAt: now() + PROMPT_ERROR_TTL_MS };
614
+ try {
615
+ const response = await fetchImpl(`${baseUrl}/api/public/v2/prompts/${encodeURIComponent(prompt.name)}?label=${encodeURIComponent(label)}`, { headers: { Authorization: authorization }, signal: AbortSignal.timeout(5_000), redirect: "error" });
616
+ if (response.status === 404) {
617
+ await response.body?.cancel().catch(() => { });
618
+ const version = await publish(key, prompt);
619
+ entry = version === undefined
620
+ ? { version: undefined, expiresAt: now() + PROMPT_MISSING_TTL_MS }
621
+ : { version, expiresAt: now() + PROMPT_FOUND_TTL_MS };
622
+ }
623
+ else if (!response.ok) {
624
+ await response.body?.cancel().catch(() => { });
625
+ warn(new Error("langfuse_prompt_link_failed"), { stage: "prompt_link", status: response.status });
626
+ }
627
+ else {
628
+ const body = await response.json();
629
+ const labels = Array.isArray(body.labels) ? body.labels : [];
630
+ if (typeof body.version === "number" && Number.isInteger(body.version) && labels.includes(label)) {
631
+ entry = { version: body.version, expiresAt: now() + PROMPT_FOUND_TTL_MS };
632
+ }
633
+ else {
634
+ entry = { version: undefined, expiresAt: now() + PROMPT_MISSING_TTL_MS };
635
+ }
636
+ }
637
+ }
638
+ catch {
639
+ warn(new Error("langfuse_prompt_link_failed"), { stage: "prompt_link" });
640
+ }
641
+ finally {
642
+ if (cache.size >= PROMPT_CACHE_LIMIT)
643
+ cache.delete(cache.keys().next().value);
644
+ cache.set(key, entry);
645
+ inflight.delete(key);
646
+ }
647
+ };
648
+ return {
649
+ versionFor: (prompt) => {
650
+ const key = `${prompt.name}\u0000${prompt.hash}`;
651
+ const cached = cache.get(key);
652
+ if ((!cached || cached.expiresAt <= now()) && !inflight.has(key)) {
653
+ inflight.add(key);
654
+ void lookup(key, prompt);
655
+ }
656
+ return cached?.version;
657
+ },
658
+ };
659
+ }
350
660
  /**
351
661
  * Construct a fail-open Langfuse client, or `null` when tracing is disabled or
352
662
  * misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
@@ -355,24 +665,65 @@ function createApiScorer(env, warn) {
355
665
  export function createLlmTracingClient(env = process.env, options) {
356
666
  if (!options?.emitterImpl && !isLlmTracingEnabled(env))
357
667
  return null;
358
- let lastWarnAtMs = 0;
668
+ const lastWarnAtMs = new Map();
359
669
  const warn = (error, context) => {
360
670
  const nowMs = Date.now();
361
- if (nowMs - lastWarnAtMs < WARN_THROTTLE_MS)
671
+ const errorCode = error instanceof Error && ["langfuse_flush_deadline_exceeded", "langfuse_replay_identity_unavailable", "langfuse_prompt_link_failed", "langfuse_observation_build_failed"].includes(error.message)
672
+ ? error.message : "langfuse_delivery_failed";
673
+ // An identity gap must not suppress a later actual delivery failure.
674
+ const warningKey = `${errorCode}:${context?.stage ?? "unknown"}`;
675
+ if (nowMs - (lastWarnAtMs.get(warningKey) ?? -Infinity) < WARN_THROTTLE_MS)
362
676
  return;
363
- lastWarnAtMs = nowMs;
677
+ lastWarnAtMs.set(warningKey, nowMs);
364
678
  log("warn", "llm_tracing.ingest_failed", {
365
679
  provider: "langfuse",
366
- error_message: error instanceof Error ? error.message : String(error),
680
+ // Exporter/API error messages can contain the rejected request or server
681
+ // response. Only this fixed classification may leave the private store.
682
+ error_code: errorCode,
367
683
  ...context,
368
684
  });
369
685
  };
370
- const transport = options?.emitterImpl ?? createOtelEmitter(env, warn);
686
+ // Opt-in per environment; managed projects retain their existing append
687
+ // semantics. A real execution is identified by the caller's immutable start
688
+ // plus logical id, never by the logical id alone (approval/tool ids can recur).
689
+ const stableIdentity = env.LANGFUSE_OBSERVATION_IDENTITY === "stable-v1";
690
+ const physicalId = (body, kind) => {
691
+ if (!stableIdentity)
692
+ return undefined;
693
+ // Verified ledger-backed control hooks supply the exact durable dedupe key.
694
+ // It already defines identity in the authoritative session/run ledger, so
695
+ // identity hashing needs no additional clock. The durable capture adapter
696
+ // separately supplies the original ledger timestamp for v3's date key.
697
+ const durableEventKey = body.metadata?.oxygen_durable_event_key;
698
+ if (kind === "event" && typeof durableEventKey === "string" && durableEventKey.length > 0) {
699
+ return observationSpanId(["langfuse-durable-event-v1", body.traceId, body.name, durableEventKey]);
700
+ }
701
+ // Approval ids name a durable decision/request across invocation slices.
702
+ // Other event keys are not universally unique; do not infer that guarantee.
703
+ if (kind === "event" && /^apr:(requested|decided):.+/.test(body.id)) {
704
+ return observationSpanId(["langfuse-event-v1", body.traceId, body.name, body.id]);
705
+ }
706
+ if (!body.startTime || !Number.isFinite(body.startTime.getTime())) {
707
+ warn(new Error("langfuse_replay_identity_unavailable"), { stage: "identity", observation_kind: kind });
708
+ return undefined;
709
+ }
710
+ return observationSpanId(["langfuse-observation-v1", body.traceId, kind, body.name, body.id,
711
+ body.startTime.toISOString(), body.metadata?.attempt_id ?? null, body.metadata?.call_id ?? null]);
712
+ };
713
+ const summary = createLlmExportSummary();
714
+ const transport = options?.emitterImpl ?? createOtelEmitter(env, warn, summary);
371
715
  const emitter = {
372
716
  ...transport,
373
717
  emit: (emission) => {
374
718
  const observationId = emission.observationId ?? randomBytes(8).toString("hex");
375
719
  const metadata = { ...emission.attributes.metadata, oxygen_capture_version: 2 };
720
+ // Cross-link to the operational trace (SigNoz/Axiom). Read here, still in
721
+ // the caller's synchronous context; the private provider never sees it.
722
+ if (metadata.otel_trace_id === undefined) {
723
+ const otelTraceId = activeTelemetryTraceId();
724
+ if (otelTraceId)
725
+ metadata.otel_trace_id = otelTraceId;
726
+ }
376
727
  const attributes = { ...emission.attributes, version: OXYGEN_VERSION, metadata };
377
728
  const chunkEmissions = [];
378
729
  const capture = (value, field, key) => {
@@ -380,12 +731,18 @@ export function createLlmTracingClient(env = process.env, options) {
380
731
  for (const chunk of prepared.chunks) {
381
732
  chunkEmissions.push({
382
733
  kind: "event", traceSeed: emission.traceSeed, name: "llm.payload_chunk", parentObservationId: observationId,
734
+ ...(stableIdentity ? { observationId: observationSpanId(["langfuse-payload-v1", observationId, field, chunk.payload_id, chunk.index]) } : {}),
383
735
  attributes: { version: OXYGEN_VERSION, input: chunk, metadata: {
384
736
  oxygen_capture_version: 2, oxygen_payload_id: chunk.payload_id,
385
737
  oxygen_payload_chunk_index: chunk.index, oxygen_payload_chunk_count: prepared.chunks.length,
386
738
  oxygen_observation_id: `${metadata.oxygen_observation_id ?? observationId}:payload:${field}:${chunk.index}`,
387
739
  } },
388
- correlation: emission.correlation, startTime: emission.endTime ?? emission.startTime, endTime: emission.endTime ?? emission.startTime,
740
+ // V3's replacing key includes the start date. The root's end can
741
+ // move on resume, while unchanged chunks retain their IDs; pin their
742
+ // date to the immutable owner start in stable mode as well.
743
+ correlation: emission.correlation,
744
+ startTime: stableIdentity ? emission.startTime : emission.endTime ?? emission.startTime,
745
+ endTime: stableIdentity ? emission.startTime : emission.endTime ?? emission.startTime,
389
746
  });
390
747
  }
391
748
  return prepared.value;
@@ -414,12 +771,18 @@ export function createLlmTracingClient(env = process.env, options) {
414
771
  },
415
772
  };
416
773
  const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
774
+ const promptResolver = options?.promptResolverImpl ?? (options?.emitterImpl ? NOOP_PROMPT_RESOLVER : createApiPromptResolver(env, warn));
417
775
  const guarded = (fn, stage) => {
418
776
  try {
419
777
  fn();
420
778
  }
421
779
  catch (error) {
422
- warn(error, { stage });
780
+ // Building the observation failed before any transport saw it (payload
781
+ // preparation, identity hashing, a substituted emitter). That is a lost
782
+ // capture, not a delivery failure, and must not read as one.
783
+ summary.dropped(1);
784
+ summary.maybeReport();
785
+ warn(new Error("langfuse_observation_build_failed"), { stage, reason: sanitizedErrorReason(error) });
423
786
  }
424
787
  };
425
788
  // propagateAttributes rejects non-string ids and anything over 200 chars, and
@@ -442,11 +805,17 @@ export function createLlmTracingClient(env = process.env, options) {
442
805
  tags: input.tags,
443
806
  });
444
807
  };
808
+ const promptLink = (prompt) => {
809
+ if (!prompt?.registered)
810
+ return {};
811
+ const version = promptResolver.versionFor(prompt);
812
+ return version === undefined ? {} : { prompt: { name: prompt.name, version, isFallback: false } };
813
+ };
445
814
  const modelObservation = (body, kind) => guarded(() => {
446
815
  emitter.emit({
447
816
  kind,
448
817
  traceSeed: body.traceId,
449
- ...(body.observationId ? { observationId: body.observationId } : {}),
818
+ observationId: body.observationId ?? physicalId(body, kind),
450
819
  name: body.name,
451
820
  attributes: compact({
452
821
  input: body.input,
@@ -458,7 +827,8 @@ export function createLlmTracingClient(env = process.env, options) {
458
827
  completionStartTime: body.completionStartTime,
459
828
  usageDetails: body.usageDetails,
460
829
  costDetails: body.costDetails,
461
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
830
+ ...promptLink(body.prompt),
831
+ metadata: { ...(body.metadata ?? {}), ...promptMetadata(body.prompt), oxygen_observation_id: body.id },
462
832
  }),
463
833
  // The cost-bearing observation: v5 session cost only rolls up when
464
834
  // the generation itself carries the session.
@@ -495,6 +865,7 @@ export function createLlmTracingClient(env = process.env, options) {
495
865
  const sliceStart = typeof body.metadata.slice_started_at === "string" ? new Date(body.metadata.slice_started_at) : startTime;
496
866
  emitter.emit({
497
867
  kind: "span", traceSeed: body.id, name: `${body.name}.slice`,
868
+ ...(stableIdentity ? { observationId: observationSpanId(["langfuse-slice-v1", body.id, body.metadata.slice_id, sliceStart.getTime()]) } : {}),
498
869
  attributes: compact({ input: body.input, output: body.output, metadata: { ...body.metadata, oxygen_observation_id: `${body.id}:slice:${body.metadata.slice_id}` } }),
499
870
  correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
500
871
  startTime: Number.isFinite(sliceStart.getTime()) ? sliceStart : startTime, endTime: body.endTime ?? startTime,
@@ -505,6 +876,7 @@ export function createLlmTracingClient(env = process.env, options) {
505
876
  emitter.emit({
506
877
  kind: "span",
507
878
  traceSeed: body.traceId,
879
+ observationId: physicalId(body, "span"),
508
880
  name: body.name,
509
881
  attributes: compact({
510
882
  input: body.input,
@@ -521,10 +893,22 @@ export function createLlmTracingClient(env = process.env, options) {
521
893
  generation: (body) => modelObservation(body, "generation"),
522
894
  embedding: (body) => modelObservation(body, "embedding"),
523
895
  event: (body) => guarded(() => {
896
+ const durableKey = body.metadata?.oxygen_durable_event_key;
897
+ const durableEvent = (typeof durableKey === "string" && durableKey.length > 0) || /^apr:(requested|decided):.+/.test(body.id);
898
+ if (stableIdentity && durableEvent && (!body.startTime || !Number.isFinite(body.startTime.getTime()))) {
899
+ // A stable id with "now" as its date creates another V3 observation
900
+ // after midnight. Missing canonical time is missing capture, not a
901
+ // reason to fabricate a historical event or duplicate it silently.
902
+ warn(new Error("langfuse_replay_identity_unavailable"), { stage: "identity", observation_kind: "event" });
903
+ summary.dropped(1);
904
+ summary.maybeReport();
905
+ return;
906
+ }
524
907
  const startTime = body.startTime ?? new Date();
525
908
  emitter.emit({
526
909
  kind: "event",
527
910
  traceSeed: body.traceId,
911
+ observationId: physicalId(body, "event"),
528
912
  name: body.name,
529
913
  attributes: compact({
530
914
  input: body.input,
@@ -551,7 +935,14 @@ export function createLlmTracingClient(env = process.env, options) {
551
935
  // enforced here (15s for serverless after()) rather than inside one emitter — an emitter that throws
552
936
  // synchronously or rejects must still not escape into product code.
553
937
  flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
554
- shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
938
+ shutdown: () => boundedNever(
939
+ // The final window is reported whatever its age: nothing follows it.
940
+ (async () => { try {
941
+ await emitter.shutdown();
942
+ }
943
+ finally {
944
+ summary.reportNow();
945
+ } })(), (error) => warn(error, { stage: "shutdown" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
555
946
  };
556
947
  }
557
948
  // --- Process-wide singleton (both runtimes construct at most one client) ------
@@ -563,6 +954,8 @@ function cacheKey(env) {
563
954
  env.LANGFUSE_SECRET_KEY ?? "",
564
955
  env.LANGFUSE_BASE_URL ?? "",
565
956
  resolveLlmTracingEnvironment(env),
957
+ resolveLlmTracingServiceName(env),
958
+ env.LANGFUSE_OBSERVATION_IDENTITY ?? "",
566
959
  ].join("|");
567
960
  }
568
961
  export function getLlmTracingClient(env = process.env) {
@@ -30,3 +30,4 @@ export declare const LINKEDIN_COUNTRIES: ReadonlyArray<readonly [code: string, n
30
30
  * honest outcome, and the raw payload is still on the record.
31
31
  */
32
32
  export declare function resolveLinkedInCountry(value: unknown): string | null;
33
+ export { LINKEDIN_COUNTRY_PRIMARY_TIME_ZONES, linkedInCountryPrimaryTimeZone, } from "./linkedin-country-timezones.js";
@@ -357,3 +357,5 @@ export function resolveLinkedInCountry(value) {
357
357
  const aliased = COUNTRY_ALIASES[lower];
358
358
  return aliased ? BY_CODE.get(aliased) ?? null : null;
359
359
  }
360
+ // The account-owner country also picks a LinkedIn sender's default working-hours zone.
361
+ export { LINKEDIN_COUNTRY_PRIMARY_TIME_ZONES, linkedInCountryPrimaryTimeZone, } from "./linkedin-country-timezones.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Primary IANA time zone for each LinkedIn country code.
3
+ *
4
+ * WHY: a LinkedIn sender's working hours and daily-quota rollover run in the
5
+ * account owner's local time. The only geo signal OXYGEN holds for a new
6
+ * account is the country the owner chose at connect ("where the account owner
7
+ * normally uses LinkedIn"), so that country picks the zone whenever no explicit
8
+ * timezone was stored. Without it every account ran on America/New_York and a
9
+ * German account was active 13:00–04:00 Berlin time.
10
+ *
11
+ * GENERATED once from ICU's region → zone data (`new Intl.Locale("und-XX")
12
+ * .timeZones`) for the codes in LINKEDIN_COUNTRIES, then frozen here so a Node
13
+ * upgrade cannot silently move an account's window. Countries spanning several
14
+ * zones carry the zone of their largest business population (US → New York,
15
+ * CA → Toronto, AU → Sydney, BR → São Paulo, RU → Moscow, …). Territories with
16
+ * no populated zone (AQ, BV, HM) are absent and fall back to the default. An
17
+ * explicit per-account timezone always wins over this table.
18
+ */
19
+ export declare const LINKEDIN_COUNTRY_PRIMARY_TIME_ZONES: Readonly<Record<string, string>>;
20
+ /**
21
+ * The primary IANA zone for an ISO 3166-1 alpha-2 country code (any case), or
22
+ * null when the code is unknown or has no populated zone.
23
+ */
24
+ export declare function linkedInCountryPrimaryTimeZone(country: unknown): string | null;