weflayr 0.16.0 → 0.20.2

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.
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const retry = require('async-retry');
4
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
5
+
6
+ const { debug } = require('../logger');
7
+
8
+ // OTLP-spec retryable statuses: rate-limit + transient server errors.
9
+ const _RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
10
+
11
+ function _isNonRetryable(err) {
12
+ // Tells async-retry's `bail` whether to give up: bail on 4xx (other than
13
+ // 429); retry on retryable statuses and on transport errors (no status).
14
+ const status = err?.exportResult?.error?.code;
15
+ return status !== undefined && !_RETRYABLE_STATUS.has(status);
16
+ }
17
+
18
+ function _formatFailure(result, url) {
19
+ const err = result?.error;
20
+ const status = err?.code;
21
+ const body = typeof err?.data === 'string' ? err.data.slice(0, 200) : undefined;
22
+ const message = err?.message || String(err) || 'unknown error';
23
+ let hint = '';
24
+ if (status === 401 || status === 403) {
25
+ hint = ' Check your apiKey / WEFLAYR_API_KEY.';
26
+ } else if (status === 404) {
27
+ hint = ' Check the baseUrl is correct.';
28
+ } else if (!status && /ECONN|ENOTFOUND|timeout/i.test(message)) {
29
+ hint = ' The endpoint is unreachable — check baseUrl and network.';
30
+ }
31
+ return (
32
+ `Weflayr: failed to export telemetry to ${url}` +
33
+ (status ? ` (HTTP ${status})` : '') +
34
+ `: ${message}.${hint}` +
35
+ (body ? ` Response: ${body}` : '')
36
+ );
37
+ }
38
+
39
+ // Wrap a callback-style OTel exporter `export(spans, cb)` with retry. Retries
40
+ // transport errors + retryable HTTP statuses; bails on other 4xx.
41
+ function _exportWithRetry(originalExport, { maxRetries, maxRetryMs }) {
42
+ return (spans, resultCallback) => {
43
+ retry(
44
+ async (bail) => {
45
+ const result = await new Promise((resolve) => originalExport(spans, resolve));
46
+ if (result?.code === 0) return result;
47
+ const err = new Error(result?.error?.message || 'export failed');
48
+ err.exportResult = result;
49
+ if (_isNonRetryable(err)) {
50
+ bail(err);
51
+ return;
52
+ }
53
+ throw err;
54
+ },
55
+ { retries: maxRetries - 1, minTimeout: 50, maxRetryTime: maxRetryMs, factor: 2 }
56
+ )
57
+ .then((result) => resultCallback(result))
58
+ .catch((err) => resultCallback(err.exportResult || { code: 1, error: err }));
59
+ };
60
+ }
61
+
62
+ function buildExporter({
63
+ baseUrl,
64
+ apiKey,
65
+ timeoutMs = 5000,
66
+ warnOnExportError = true,
67
+ maxRetries = 2,
68
+ maxRetryMs = 1000,
69
+ }) {
70
+ const url = `${baseUrl.replace(/\/$/, '')}/`;
71
+ const exporter = new OTLPTraceExporter({
72
+ url,
73
+ headers: {
74
+ Authorization: `Bearer ${apiKey}`,
75
+ },
76
+ timeoutMillis: timeoutMs,
77
+ });
78
+ const originalExport = exporter.export.bind(exporter);
79
+ const wrappedExport = _exportWithRetry(originalExport, { maxRetries, maxRetryMs });
80
+ let warned = false;
81
+ exporter.export = (spans, resultCallback) => {
82
+ const start = Date.now();
83
+ debug('exporter.export: posting count=%d', spans.length);
84
+ wrappedExport(spans, (result) => {
85
+ const ms = Date.now() - start;
86
+ if (result && result.code === 0) {
87
+ debug('exporter.export: ok count=%d elapsed_ms=%d', spans.length, ms);
88
+ } else {
89
+ debug(
90
+ 'exporter.export: FAIL count=%d elapsed_ms=%d code=%s error=%s',
91
+ spans.length,
92
+ ms,
93
+ result?.code,
94
+ result?.error?.message || result?.error || '(none)'
95
+ );
96
+ // Surface the first failure on stderr so users notice broken setups
97
+ // even without NODE_DEBUG=weflayr. Stay quiet after — repeated warns
98
+ // on a misconfigured key would just spam.
99
+ if (warnOnExportError && !warned) {
100
+ warned = true;
101
+ // console.warn writes to stderr synchronously. process.emitWarning
102
+ // is queued for the next tick, so a caller doing process.exit() from
103
+ // their error handler races past it and the warning is lost.
104
+ console.warn(`[weflayr] ${_formatFailure(result, url)}`);
105
+ }
106
+ }
107
+ resultCallback(result);
108
+ });
109
+ };
110
+ return exporter;
111
+ }
112
+
113
+ module.exports = { buildExporter, _exportWithRetry, _RETRYABLE_STATUS };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ // Backfill token usage onto streamed OpenAI spans.
4
+ //
5
+ // @traceloop/instrumentation-openai (the OpenAI OTel instrumentor we load for
6
+ // the `openai` SDK) does not record token usage for streamed calls: its stream
7
+ // handler accumulates `choices[].delta` but never reads the final usage chunk
8
+ // that OpenAI sends when the request sets `stream_options: { include_usage:
9
+ // true }`. Non-streaming calls work because OpenAI returns `usage` in the
10
+ // response body, which the instrumentor reads directly. The Python OpenLLMetry
11
+ // instrumentor does read the streamed usage, so this gap is JS-only.
12
+ //
13
+ // Read `usage` from the streamed chunks. The instrumentor's streaming handler hands us the span
14
+ // and ends it only after its chunk loop, so when the usage chunk arrives the
15
+ // span is still open and we set the same `gen_ai.usage.*` attributes the
16
+ // instrumentor sets for non-streaming calls.
17
+
18
+ const USAGE_INPUT_TOKENS_ATTR = 'gen_ai.usage.input_tokens';
19
+ const USAGE_OUTPUT_TOKENS_ATTR = 'gen_ai.usage.output_tokens';
20
+ const USAGE_TOTAL_TOKENS_ATTR = 'gen_ai.usage.total_tokens';
21
+
22
+ function _setUsageAttributes(span, usage) {
23
+ if (typeof usage.prompt_tokens === 'number') {
24
+ span.setAttribute(USAGE_INPUT_TOKENS_ATTR, usage.prompt_tokens);
25
+ }
26
+ if (typeof usage.completion_tokens === 'number') {
27
+ span.setAttribute(USAGE_OUTPUT_TOKENS_ATTR, usage.completion_tokens);
28
+ }
29
+ if (typeof usage.total_tokens === 'number') {
30
+ span.setAttribute(USAGE_TOTAL_TOKENS_ATTR, usage.total_tokens);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Wrap the OpenAI instrumentor's streaming handler so streamed spans carry
36
+ * token usage. No-op if the instrumentor's internals have changed shape — the
37
+ * worst case is streamed spans without token counts, never a crash.
38
+ */
39
+ function patchOpenAIStreamingUsage(instrumentation) {
40
+ const originalStreamingWrap = instrumentation._streamingWrapPromise;
41
+ if (typeof originalStreamingWrap !== 'function') return;
42
+
43
+ instrumentation._streamingWrapPromise = function streamingWrapWithUsage(streamArgs) {
44
+ const span = streamArgs && streamArgs.span;
45
+ const wrappedStream = originalStreamingWrap.call(this, streamArgs);
46
+ if (!span) return wrappedStream;
47
+
48
+ return (async function* backfillUsage() {
49
+ for await (const chunk of wrappedStream) {
50
+ const usage = chunk && chunk.usage;
51
+ if (usage && span.isRecording()) {
52
+ _setUsageAttributes(span, usage);
53
+ }
54
+ yield chunk;
55
+ }
56
+ })();
57
+ };
58
+ }
59
+
60
+ module.exports = { patchOpenAIStreamingUsage };
@@ -0,0 +1,119 @@
1
+ // Copyright 2024 Langfuse GmbH
2
+ // Licensed under the MIT License.
3
+ // Original source: https://github.com/langfuse/langfuse-js/blob/main/packages/core/src/propagation.ts
4
+ //
5
+ // Modifications copyright 2026 Weflayr
6
+ // This file has been modified from its original form:
7
+ // - Translated from TypeScript to JavaScript
8
+ // - Replaced Langfuse's fixed schema with Weflayr's structured metadata
9
+ // (featureName, customerId, providerName, extraTags) plus an
10
+ // auto-generated per-call uuid
11
+ // - Removed asBaggage cross-process propagation (not needed yet)
12
+ // - Removed experiment framework keys
13
+ // - Structured fields are written under stable `weflayr.*` span attribute
14
+ // keys; extraTags entries are written verbatim
15
+ //
16
+ // See LICENSE-MIT in the package root for the full license text.
17
+
18
+ 'use strict';
19
+
20
+ const { randomUUID } = require('node:crypto');
21
+ const { context: otelContext, trace, createContextKey } = require('@opentelemetry/api');
22
+
23
+ const WEFLAYR_METADATA_CONTEXT_KEY = createContextKey('weflayr_metadata');
24
+
25
+ const FEATURE_NAME_ATTR = 'weflayr.feature_name';
26
+ const CUSTOMER_ID_ATTR = 'weflayr.customer_id';
27
+ const PROVIDER_NAME_ATTR = 'weflayr.provider_name';
28
+ const UUID_ATTR = 'weflayr.uuid';
29
+ // Prefix for user-supplied `extraTags` so downstream normalizers can pick
30
+ // them out without colliding with instrumentor-set `gen_ai.*` attributes.
31
+ const EXTRA_TAG_PREFIX = 'weflayr.tag.';
32
+
33
+ function getMergedMetadataFromContext(parentContext) {
34
+ const ctx = parentContext ?? otelContext.active();
35
+ const existing = ctx.getValue(WEFLAYR_METADATA_CONTEXT_KEY);
36
+ return existing && typeof existing === 'object' ? { ...existing } : {};
37
+ }
38
+
39
+ function _buildAttributes({ featureName, customerId, providerName, extraTags, spanUuid }) {
40
+ const attributes = {
41
+ [FEATURE_NAME_ATTR]: featureName,
42
+ [CUSTOMER_ID_ATTR]: customerId,
43
+ [UUID_ATTR]: spanUuid,
44
+ };
45
+ for (const [key, value] of Object.entries(extraTags)) {
46
+ attributes[`${EXTRA_TAG_PREFIX}${key}`] = value;
47
+ }
48
+ if (providerName !== undefined && providerName !== null) {
49
+ attributes[PROVIDER_NAME_ATTR] = providerName;
50
+ }
51
+ return attributes;
52
+ }
53
+
54
+ /**
55
+ * Attach Weflayr metadata to every LLM span created inside this scope.
56
+ *
57
+ * Callable form: `propagateMetadata(options, fn)` runs `fn` immediately.
58
+ * Decorator form: `propagateMetadata(options)` returns a wrapper usable as a
59
+ * function-decorator (`@propagateMetadata(...)` on class methods) or as a
60
+ * plain `wrap(fn)` -> wrappedFn helper. Each invocation of the wrapped
61
+ * function mints a fresh `uuid`, matching the callable form's "one uuid per
62
+ * propagateMetadata call" contract.
63
+ */
64
+ function propagateMetadata(options, fn) {
65
+ if (typeof fn === 'function') {
66
+ return _runWithMetadata(options, fn);
67
+ }
68
+
69
+ return function decorator(target, key, descriptor) {
70
+ if (descriptor && typeof descriptor.value === 'function') {
71
+ const original = descriptor.value;
72
+ descriptor.value = function wrapped(...args) {
73
+ return _runWithMetadata(options, () => original.apply(this, args));
74
+ };
75
+ return descriptor;
76
+ }
77
+ if (typeof target === 'function') {
78
+ return function wrapped(...args) {
79
+ return _runWithMetadata(options, () => target.apply(this, args));
80
+ };
81
+ }
82
+ return target;
83
+ };
84
+ }
85
+
86
+ function _runWithMetadata(options, fn) {
87
+ const { featureName, customerId, providerName, extraTags } = options;
88
+ const attributes = _buildAttributes({
89
+ featureName,
90
+ customerId,
91
+ providerName,
92
+ extraTags: extraTags ?? {},
93
+ spanUuid: randomUUID().replace(/-/g, ''),
94
+ });
95
+
96
+ const parent = otelContext.active();
97
+ const merged = { ...getMergedMetadataFromContext(parent), ...attributes };
98
+ const newContext = parent.setValue(WEFLAYR_METADATA_CONTEXT_KEY, merged);
99
+
100
+ const activeSpan = trace.getActiveSpan();
101
+ if (activeSpan && activeSpan.isRecording && activeSpan.isRecording()) {
102
+ for (const [k, v] of Object.entries(attributes)) {
103
+ activeSpan.setAttribute(k, v);
104
+ }
105
+ }
106
+
107
+ return otelContext.with(newContext, fn);
108
+ }
109
+
110
+ module.exports = {
111
+ propagateMetadata,
112
+ getMergedMetadataFromContext,
113
+ WEFLAYR_METADATA_CONTEXT_KEY,
114
+ FEATURE_NAME_ATTR,
115
+ CUSTOMER_ID_ATTR,
116
+ PROVIDER_NAME_ATTR,
117
+ UUID_ATTR,
118
+ EXTRA_TAG_PREFIX,
119
+ };
@@ -0,0 +1,73 @@
1
+ // Copyright 2024 Langfuse GmbH
2
+ // Licensed under the MIT License.
3
+ // Original source: https://github.com/langfuse/langfuse-js/blob/main/packages/otel/src/span-filter.ts
4
+ //
5
+ // Modifications copyright 2026 Weflayr
6
+ // This file has been modified from its original form:
7
+ // - Translated from TypeScript to JavaScript
8
+ // - Dropped the LANGFUSE_TRACER_NAME special case (we have no SDK-internal tracer)
9
+ // - Dropped isLangfuseSpan
10
+ //
11
+ // See LICENSE-MIT in the package root for the full license text.
12
+
13
+ 'use strict';
14
+
15
+ const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
16
+ 'agent_framework',
17
+ 'ai',
18
+ 'haystack',
19
+ 'langsmith',
20
+ 'litellm',
21
+ 'openinference',
22
+ 'opentelemetry.instrumentation.anthropic',
23
+ 'opentelemetry.instrumentation.aws_bedrock',
24
+ 'opentelemetry.instrumentation.bedrock',
25
+ 'opentelemetry.instrumentation.gemini',
26
+ 'opentelemetry.instrumentation.google_genai',
27
+ 'opentelemetry.instrumentation.google_generativeai',
28
+ 'opentelemetry.instrumentation.openai',
29
+ 'opentelemetry.instrumentation.openai_v2',
30
+ 'opentelemetry.instrumentation.vertex_ai',
31
+ 'opentelemetry.instrumentation.vertexai',
32
+ 'strands-agents',
33
+ 'vllm',
34
+ '@traceloop/instrumentation-openai',
35
+ '@traceloop/instrumentation-anthropic',
36
+ '@traceloop/instrumentation-bedrock',
37
+ '@traceloop/instrumentation-azure',
38
+ '@traceloop/instrumentation-cohere',
39
+ '@traceloop/instrumentation-together',
40
+ '@traceloop/instrumentation-vertexai',
41
+ '@traceloop/instrumentation-google-generativeai',
42
+ '@traceloop/instrumentation-langchain',
43
+ '@traceloop/instrumentation-llamaindex',
44
+ '@traceloop/instrumentation-pinecone',
45
+ '@traceloop/instrumentation-chromadb',
46
+ '@traceloop/instrumentation-qdrant',
47
+ '@traceloop/instrumentation-mcp',
48
+ ];
49
+
50
+ const EXACT_LLM_INSTRUMENTATION_SCOPES = new Set(['ai']);
51
+
52
+ function isGenAISpan(span) {
53
+ return Object.keys(span.attributes).some((key) => key.startsWith('gen_ai.'));
54
+ }
55
+
56
+ function isKnownLLMInstrumentor(span) {
57
+ const scope = span.instrumentationScope.name;
58
+ return KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.some(
59
+ (prefix) =>
60
+ scope === prefix ||
61
+ (!EXACT_LLM_INSTRUMENTATION_SCOPES.has(prefix) && scope.startsWith(`${prefix}.`))
62
+ );
63
+ }
64
+
65
+ function isDefaultExportSpan(span) {
66
+ return isGenAISpan(span) || isKnownLLMInstrumentor(span);
67
+ }
68
+
69
+ module.exports = {
70
+ isGenAISpan,
71
+ isKnownLLMInstrumentor,
72
+ isDefaultExportSpan,
73
+ };
@@ -0,0 +1,123 @@
1
+ // Copyright 2024 Langfuse GmbH
2
+ // Licensed under the MIT License.
3
+ // Original source: https://github.com/langfuse/langfuse-js/blob/main/packages/otel/src/span-processor.ts
4
+ //
5
+ // Modifications copyright 2026 Weflayr
6
+ // This file has been modified from its original form:
7
+ // - Translated from TypeScript to JavaScript
8
+ // - Removed MediaService integration (no media upload support yet)
9
+ // - Removed LangfuseAPIClient / app-root marker logic (not used)
10
+ // - Removed Langfuse-specific span attributes (TRACE_INPUT/OUTPUT/METADATA, IS_APP_ROOT, ENVIRONMENT, RELEASE)
11
+ // - Mask hook now receives the full flat attribute bag, not a {data} envelope, and runs synchronously
12
+ // - default_tags are applied as span attributes in onStart
13
+ // - propagated tags come from Weflayr's tag bag (see propagation.js), not Langfuse's typed schema
14
+ //
15
+ // See LICENSE-MIT in the package root for the full license text.
16
+
17
+ 'use strict';
18
+
19
+ const { SpanStatusCode } = require('@opentelemetry/api');
20
+ const { BatchSpanProcessor, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
21
+ const { EXTRA_TAG_PREFIX, getMergedMetadataFromContext } = require('./propagation');
22
+ const { isDefaultExportSpan, isKnownLLMInstrumentor } = require('./span-filter');
23
+ const { debug } = require('../logger');
24
+
25
+ class WeflayrSpanProcessor {
26
+ constructor({ exporter, exportMode = 'batch', defaultTags = {}, mask, shouldExportSpan } = {}) {
27
+ if (!exporter) {
28
+ throw new Error('WeflayrSpanProcessor: exporter is required');
29
+ }
30
+ this._delegate =
31
+ exportMode === 'immediate'
32
+ ? new SimpleSpanProcessor(exporter)
33
+ : new BatchSpanProcessor(exporter);
34
+ this._defaultTags = defaultTags;
35
+ this._mask = mask;
36
+ this._shouldExportSpan = shouldExportSpan ?? isDefaultExportSpan;
37
+ // LLM spans we've started but not yet seen end. On forceFlush we end any
38
+ // leftovers with ERROR status — covers cases like openllmetry's anthropic
39
+ // streaming wrapper, which leaks the span when the underlying call
40
+ // rejects. Generic across providers; no per-provider patching needed.
41
+ this._openLlmSpans = new Set();
42
+ }
43
+
44
+ onStart(span, parentContext) {
45
+ for (const [k, v] of Object.entries(this._defaultTags)) {
46
+ span.setAttribute(`${EXTRA_TAG_PREFIX}${k}`, v);
47
+ }
48
+ const propagated = getMergedMetadataFromContext(parentContext);
49
+ for (const [k, v] of Object.entries(propagated)) {
50
+ span.setAttribute(k, v);
51
+ }
52
+ if (isKnownLLMInstrumentor(span)) {
53
+ this._openLlmSpans.add(span);
54
+ }
55
+ debug(
56
+ 'span start: name=%s defaultTags=%d propagatedMetadata=%d',
57
+ span.name,
58
+ Object.keys(this._defaultTags).length,
59
+ Object.keys(propagated).length
60
+ );
61
+ return this._delegate.onStart(span, parentContext);
62
+ }
63
+
64
+ onEnd(span) {
65
+ this._openLlmSpans.delete(span);
66
+ try {
67
+ if (!this._shouldExportSpan(span)) {
68
+ debug(
69
+ 'span end: filtered out name=%s scope=%s',
70
+ span.name,
71
+ span.instrumentationScope?.name
72
+ );
73
+ return;
74
+ }
75
+ } catch {
76
+ return;
77
+ }
78
+ debug('span end: exporting name=%s scope=%s', span.name, span.instrumentationScope?.name);
79
+
80
+ if (this._mask) {
81
+ try {
82
+ const masked = this._mask(span.attributes);
83
+ if (masked && masked !== span.attributes) {
84
+ for (const k of Object.keys(span.attributes)) delete span.attributes[k];
85
+ Object.assign(span.attributes, masked);
86
+ }
87
+ } catch {
88
+ for (const k of Object.keys(span.attributes)) delete span.attributes[k];
89
+ }
90
+ }
91
+
92
+ return this._delegate.onEnd(span);
93
+ }
94
+
95
+ async forceFlush() {
96
+ this._endOrphanedLlmSpans();
97
+ return this._delegate.forceFlush();
98
+ }
99
+
100
+ _endOrphanedLlmSpans() {
101
+ const orphans = [...this._openLlmSpans];
102
+ this._openLlmSpans.clear();
103
+ for (const span of orphans) {
104
+ try {
105
+ if (typeof span.isRecording === 'function' && !span.isRecording()) continue;
106
+ span.setStatus({
107
+ code: SpanStatusCode.ERROR,
108
+ message: 'weflayr: span did not complete before flush',
109
+ });
110
+ span.end();
111
+ debug('forceFlush: ended orphaned LLM span name=%s', span.name);
112
+ } catch (err) {
113
+ debug('forceFlush: failed to end orphan span: %s', err?.message || err);
114
+ }
115
+ }
116
+ }
117
+
118
+ shutdown() {
119
+ return this._delegate.shutdown();
120
+ }
121
+ }
122
+
123
+ module.exports = { WeflayrSpanProcessor };
package/src/setup.js ADDED
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const { trace } = require('@opentelemetry/api');
4
+ const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
5
+ const { resourceFromAttributes } = require('@opentelemetry/resources');
6
+
7
+ const { buildExporter } = require('./otel/exporter');
8
+ const { WeflayrSpanProcessor } = require('./otel/span-processor');
9
+ const { debug } = require('./logger');
10
+ const { version: SDK_VERSION } = require('../package.json');
11
+
12
+ const SDK_LANGUAGE = 'javascript';
13
+ const DEFAULT_BASE_URL = 'https://api.weflayr.com';
14
+ const CONTENT_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
15
+
16
+ const _state = {
17
+ tracerProvider: null,
18
+ processor: null,
19
+ beforeExitListener: null,
20
+ };
21
+
22
+ // `trace.getTracerProvider()` returns a ProxyTracerProvider that forwards to
23
+ // whoever called `.register()` last. Unwrap it to reach the real provider.
24
+ function _unwrapTracerProvider(p) {
25
+ if (p && typeof p.getDelegate === 'function') {
26
+ return p.getDelegate();
27
+ }
28
+ return p;
29
+ }
30
+
31
+ // `NodeTracerProvider` 2.x dropped the public `addSpanProcessor` API —
32
+ // processors are construction-time only. To attach to a provider somebody
33
+ // else owns, we splice into the internal MultiSpanProcessor. This matches
34
+ // the actual public OTel JS guidance for composing post-construction.
35
+ function _attachProcessorToExistingProvider(provider, processor) {
36
+ if (!provider) return false;
37
+ if (typeof provider.addSpanProcessor === 'function') {
38
+ provider.addSpanProcessor(processor);
39
+ return true;
40
+ }
41
+ const multi = provider._activeSpanProcessor;
42
+ if (multi && Array.isArray(multi._spanProcessors)) {
43
+ multi._spanProcessors.push(processor);
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Configure the Weflayr SDK.
51
+ *
52
+ * Call once per process, before `autoInstrument`. For per-request or
53
+ * per-tenant data in a long-running server use `propagateMetadata(...)` —
54
+ * it stores metadata in OTel's `Context` (built on AsyncLocalStorage) and
55
+ * isolates correctly across concurrent requests.
56
+ *
57
+ * Calling `setup` again is supported but replaces the previous
58
+ * configuration: the prior processor is shut down and detached so we never
59
+ * stack exporters (which would otherwise double-export every span and leak
60
+ * HTTP pools).
61
+ */
62
+ function setup(options) {
63
+ const opts = options || {};
64
+ const apiKey = opts.apiKey || process.env.WEFLAYR_API_KEY;
65
+ if (typeof apiKey !== 'string' || !apiKey) {
66
+ throw new Error(
67
+ 'weflayr.setup: `apiKey` is required (pass it explicitly or set WEFLAYR_API_KEY)'
68
+ );
69
+ }
70
+ options = { ...opts, apiKey };
71
+
72
+ if (options.captureMessageContent === false) {
73
+ process.env[CONTENT_CAPTURE_ENV] = 'false';
74
+ } else if (options.captureMessageContent === true) {
75
+ process.env[CONTENT_CAPTURE_ENV] = 'true';
76
+ }
77
+
78
+ _detachPreviousWeflayrProcessor();
79
+
80
+ const baseUrl = options.baseUrl || process.env.WEFLAYR_BASE_URL || DEFAULT_BASE_URL;
81
+
82
+ const exporter = buildExporter({
83
+ baseUrl,
84
+ apiKey: options.apiKey,
85
+ warnOnExportError: options.warnOnExportError !== false,
86
+ });
87
+
88
+ const processor = new WeflayrSpanProcessor({
89
+ exporter,
90
+ exportMode: options.exportMode || 'batch',
91
+ defaultTags: options.defaultTags || {},
92
+ mask: options.mask,
93
+ });
94
+
95
+ // Adopt an existing TracerProvider when there is one — e.g. the user already
96
+ // configured Datadog/Honeycomb/OTel, or called weflayr.setup() before.
97
+ // provider.register() silently refuses to override the proxy if another
98
+ // provider is already wired in, so naively creating a new provider would
99
+ // leave our processor attached to a dead one and we'd drop every LLM span.
100
+ const currentTracerProvider = _unwrapTracerProvider(trace.getTracerProvider());
101
+ let tracerProvider;
102
+ if (_attachProcessorToExistingProvider(currentTracerProvider, processor)) {
103
+ tracerProvider = currentTracerProvider;
104
+ } else {
105
+ tracerProvider = new NodeTracerProvider({
106
+ resource: resourceFromAttributes({
107
+ sdk_version: SDK_VERSION,
108
+ sdk_language: SDK_LANGUAGE,
109
+ }),
110
+ spanProcessors: [processor],
111
+ });
112
+ tracerProvider.register();
113
+ }
114
+
115
+ // beforeExit fires when the event loop empties on a clean exit, giving us a
116
+ // last async tick to drain buffered spans. It does NOT fire on process.exit()
117
+ // or uncaught fatals — callers handling those paths still need to call flush
118
+ // explicitly (e.g. in a finally) for the OTLP HTTP request to complete.
119
+ if (!_state.beforeExitListener) {
120
+ _state.beforeExitListener = async () => {
121
+ try {
122
+ await flush();
123
+ } catch {
124
+ // best-effort: process is already winding down.
125
+ }
126
+ };
127
+ process.on('beforeExit', _state.beforeExitListener);
128
+ }
129
+
130
+ _state.tracerProvider = tracerProvider;
131
+ _state.processor = processor;
132
+ debug(
133
+ 'setup ready: baseUrl=%s exportMode=%s defaultTags=%d',
134
+ baseUrl,
135
+ options.exportMode || 'batch',
136
+ Object.keys(options.defaultTags || {}).length
137
+ );
138
+ }
139
+
140
+ async function flush() {
141
+ if (!_state.processor) return;
142
+ debug('flush: forcing exporter flush');
143
+ await _state.processor.forceFlush();
144
+ }
145
+
146
+ // Shut down + detach the prior weflayr processor — idempotency for setup().
147
+ // Without this, calling setup() twice (key rotation, hot reload, tests that
148
+ // reconfigure) would leave the first processor wired in alongside the new
149
+ // one, double-exporting every span and leaking exporter HTTP pools.
150
+ function _detachPreviousWeflayrProcessor() {
151
+ const previousProcessor = _state.processor;
152
+ if (!previousProcessor) return;
153
+ const tp = _state.tracerProvider;
154
+ const multi = tp && tp._activeSpanProcessor;
155
+ if (multi && Array.isArray(multi._spanProcessors)) {
156
+ multi._spanProcessors = multi._spanProcessors.filter(
157
+ (processor) => processor !== previousProcessor
158
+ );
159
+ }
160
+ try {
161
+ previousProcessor.shutdown();
162
+ } catch {
163
+ // best-effort cleanup.
164
+ }
165
+ _state.processor = null;
166
+ }
167
+
168
+ module.exports = { setup, flush, _state };