weflayr 0.21.0 → 0.22.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/.prettierignore +2 -0
- package/dist/api/caseConversion.d.ts +16 -0
- package/dist/api/caseConversion.js +45 -0
- package/dist/api/generated/client/client.gen.d.ts +2 -0
- package/dist/api/generated/client/client.gen.js +220 -0
- package/dist/api/generated/client/index.d.ts +10 -0
- package/dist/api/generated/client/index.js +17 -0
- package/dist/api/generated/client/types.gen.d.ts +120 -0
- package/dist/api/generated/client/types.gen.js +3 -0
- package/dist/api/generated/client/utils.gen.d.ts +37 -0
- package/dist/api/generated/client/utils.gen.js +239 -0
- package/dist/api/generated/client.gen.d.ts +12 -0
- package/dist/api/generated/client.gen.js +6 -0
- package/dist/api/generated/core/auth.gen.d.ts +25 -0
- package/dist/api/generated/core/auth.gen.js +18 -0
- package/dist/api/generated/core/bodySerializer.gen.d.ts +25 -0
- package/dist/api/generated/core/bodySerializer.gen.js +60 -0
- package/dist/api/generated/core/params.gen.d.ts +43 -0
- package/dist/api/generated/core/params.gen.js +112 -0
- package/dist/api/generated/core/pathSerializer.gen.d.ts +33 -0
- package/dist/api/generated/core/pathSerializer.gen.js +115 -0
- package/dist/api/generated/core/queryKeySerializer.gen.d.ts +18 -0
- package/dist/api/generated/core/queryKeySerializer.gen.js +98 -0
- package/dist/api/generated/core/serverSentEvents.gen.d.ts +71 -0
- package/dist/api/generated/core/serverSentEvents.gen.js +135 -0
- package/dist/api/generated/core/types.gen.d.ts +83 -0
- package/dist/api/generated/core/types.gen.js +3 -0
- package/dist/api/generated/core/utils.gen.d.ts +19 -0
- package/dist/api/generated/core/utils.gen.js +93 -0
- package/dist/api/generated/facade.gen.d.ts +208 -0
- package/dist/api/generated/facade.gen.js +52 -0
- package/dist/api/generated/index.d.ts +2 -0
- package/dist/api/generated/index.js +17 -0
- package/dist/api/generated/sdk.gen.d.ts +63 -0
- package/dist/api/generated/sdk.gen.js +125 -0
- package/dist/api/generated/types.gen.d.ts +711 -0
- package/dist/api/generated/types.gen.js +3 -0
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +48 -0
- package/dist/api/makeEndpoint.d.ts +27 -0
- package/dist/api/makeEndpoint.js +37 -0
- package/index.d.ts +25 -16
- package/openapi-ts.config.mjs +9 -0
- package/package.json +9 -2
- package/scripts/generate_facade.mjs +86 -0
- package/src/api/caseConversion.ts +58 -0
- package/src/api/generated/client/client.gen.ts +277 -0
- package/src/api/generated/client/index.ts +27 -0
- package/src/api/generated/client/types.gen.ts +218 -0
- package/src/api/generated/client/utils.gen.ts +316 -0
- package/src/api/generated/client.gen.ts +16 -0
- package/src/api/generated/core/auth.gen.ts +48 -0
- package/src/api/generated/core/bodySerializer.gen.ts +82 -0
- package/src/api/generated/core/params.gen.ts +178 -0
- package/src/api/generated/core/pathSerializer.gen.ts +171 -0
- package/src/api/generated/core/queryKeySerializer.gen.ts +117 -0
- package/src/api/generated/core/serverSentEvents.gen.ts +242 -0
- package/src/api/generated/core/types.gen.ts +110 -0
- package/src/api/generated/core/utils.gen.ts +140 -0
- package/src/api/generated/facade.gen.ts +80 -0
- package/src/api/generated/index.ts +4 -0
- package/src/api/generated/sdk.gen.ts +139 -0
- package/src/api/generated/types.gen.ts +792 -0
- package/src/api/index.ts +45 -0
- package/src/api/makeEndpoint.ts +54 -0
- package/src/auto-instrument.js +19 -6
- package/src/index.js +5 -0
- package/src/instrumentation/anthropic/get_streaming_stop_reason_telemetry.js +121 -0
- package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
- package/src/instrumentation/anthropic/set_telemetry_attributes.js +13 -3
- package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
- package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
- package/src/instrumentation/bedrock/set_telemetry_attributes.js +12 -3
- package/src/instrumentation/cohere/get_embeddings_telemetry.js +10 -0
- package/src/instrumentation/google/get_audio_telemetry.js +1 -2
- package/src/instrumentation/google/get_cache_telemetry.js +1 -3
- package/src/instrumentation/google/get_embeddings_telemetry.js +22 -1
- package/src/instrumentation/google/get_provider_name.js +2 -1
- package/src/instrumentation/openai/get_embeddings_telemetry.js +12 -0
- package/src/instrumentation/vercel/full_instrumentation.js +44 -0
- package/src/otel/exporter.js +1 -6
- package/src/otel/propagation.js +21 -12
- package/src/otel/span-processor.js +41 -2
- package/src/otel/vercel-ai-sdk-span-filter.js +79 -0
- package/src/setup.js +1 -2
- package/tests/api/index.test.js +330 -0
- package/tests/index.test.js +150 -15
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +33 -0
- package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
- package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
- package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
- package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
- package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +3 -0
- package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +18 -4
- package/tests/instrumentation/google/get_embeddings_telemetry.test.js +3 -0
- package/tests/instrumentation/google/set_telemetry_attributes.test.js +33 -1
- package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +19 -0
- package/tests/instrumentation/openai/get_streaming_usage.test.js +29 -0
- package/tests/instrumentation/vercel/full_instrumentation.test.js +16 -0
- package/tests/otel/vercel-ai-sdk-span-filter.test.js +159 -0
- package/tsconfig.json +18 -0
|
@@ -60,6 +60,18 @@ class OpenAIEmbeddingsInstrumentor {
|
|
|
60
60
|
if (body && body.model) {
|
|
61
61
|
span.setAttribute('gen_ai.request.model', body.model);
|
|
62
62
|
}
|
|
63
|
+
// One user message per input element, matching the shape the chat
|
|
64
|
+
// instrumentors emit. The span processor strips it when message-content
|
|
65
|
+
// capture is disabled.
|
|
66
|
+
if (body && body.input != null) {
|
|
67
|
+
const inputs = Array.isArray(body.input) ? body.input : [body.input];
|
|
68
|
+
span.setAttribute(
|
|
69
|
+
'gen_ai.input.messages',
|
|
70
|
+
JSON.stringify(
|
|
71
|
+
inputs.map((content) => ({ role: 'user', parts: [{ type: 'text', content }] }))
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
63
75
|
return Promise.resolve(original.call(this, body, ...rest)).then(
|
|
64
76
|
(response) => {
|
|
65
77
|
if (response && response.model) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Instrumentor for the Vercel AI SDK (`ai` package, v7+).
|
|
5
|
+
*
|
|
6
|
+
* The AI SDK ships its own OTel integration in `@ai-sdk/otel`; instrumenting
|
|
7
|
+
* is registering it globally so every generateText/streamText/embed call emits
|
|
8
|
+
* GenAI semconv spans through the tracer provider weflayr's setup() installed.
|
|
9
|
+
* `usage: true` adds the `ai.usage.*` token details (reasoning tokens, uncached
|
|
10
|
+
* input) that the semconv attributes do not carry.
|
|
11
|
+
*
|
|
12
|
+
* The aggregate spans the integration emits around each model call
|
|
13
|
+
* (invoke_agent, agent_step, duplicated embeddings spans) are filtered out
|
|
14
|
+
* before export by otel/vercel-ai-sdk-span-filter.js.
|
|
15
|
+
*/
|
|
16
|
+
class VercelAiSdkInstrumentor {
|
|
17
|
+
instrument() {
|
|
18
|
+
let registerTelemetry;
|
|
19
|
+
let OpenTelemetry;
|
|
20
|
+
try {
|
|
21
|
+
({ registerTelemetry } = require('ai'));
|
|
22
|
+
({ OpenTelemetry } = require('@ai-sdk/otel'));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'weflayr.autoInstrument("vercel_ai_gateway"): failed to load "ai" / "@ai-sdk/otel". ' +
|
|
26
|
+
`Install them with: npm install ai @ai-sdk/otel\nUnderlying error: ${err.message}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
// registerTelemetry only exists since v7.
|
|
30
|
+
if (typeof registerTelemetry !== 'function') {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'weflayr.autoInstrument("vercel_ai_gateway") requires version 7+ of the Vercel AI SDK ' +
|
|
33
|
+
'(the "ai" package), which added global telemetry registration. ' +
|
|
34
|
+
'Upgrade with: npm install ai@latest @ai-sdk/otel.\n' +
|
|
35
|
+
'On ai <= 6 telemetry can only be enabled per call: remove "vercel_ai_gateway" from ' +
|
|
36
|
+
'aiSdks (pass an empty list if no other sdks need to be instrumented). Instead, make sure ' +
|
|
37
|
+
'experimental_telemetry: { isEnabled: true } is passed on each generateText/streamText call.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
registerTelemetry(new OpenTelemetry({ usage: true }));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { VercelAiSdkInstrumentor };
|
package/src/otel/exporter.js
CHANGED
|
@@ -77,7 +77,6 @@ function buildExporter({
|
|
|
77
77
|
});
|
|
78
78
|
const originalExport = exporter.export.bind(exporter);
|
|
79
79
|
const wrappedExport = _exportWithRetry(originalExport, { maxRetries, maxRetryMs });
|
|
80
|
-
let warned = false;
|
|
81
80
|
exporter.export = (spans, resultCallback) => {
|
|
82
81
|
const start = Date.now();
|
|
83
82
|
debug('exporter.export: posting count=%d', spans.length);
|
|
@@ -93,11 +92,7 @@ function buildExporter({
|
|
|
93
92
|
result?.code,
|
|
94
93
|
result?.error?.message || result?.error || '(none)'
|
|
95
94
|
);
|
|
96
|
-
|
|
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;
|
|
95
|
+
if (warnOnExportError) {
|
|
101
96
|
// console.warn writes to stderr synchronously. process.emitWarning
|
|
102
97
|
// is queued for the next tick, so a caller doing process.exit() from
|
|
103
98
|
// their error handler races past it and the warning is lost.
|
package/src/otel/propagation.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// This file has been modified from its original form:
|
|
7
7
|
// - Translated from TypeScript to JavaScript
|
|
8
8
|
// - Replaced Langfuse's fixed schema with Weflayr's structured metadata
|
|
9
|
-
// (featureName,
|
|
9
|
+
// (featureName, customerName, providerName, extraTags) plus an
|
|
10
10
|
// auto-generated per-call uuid
|
|
11
11
|
// - Removed asBaggage cross-process propagation (not needed yet)
|
|
12
12
|
// - Removed experiment framework keys
|
|
@@ -23,7 +23,7 @@ const { context: otelContext, trace, createContextKey } = require('@opentelemetr
|
|
|
23
23
|
const WEFLAYR_METADATA_CONTEXT_KEY = createContextKey('weflayr_metadata');
|
|
24
24
|
|
|
25
25
|
const FEATURE_NAME_ATTR = 'weflayr.feature_name';
|
|
26
|
-
const
|
|
26
|
+
const CUSTOMER_NAME_ATTR = 'weflayr.customer_name';
|
|
27
27
|
const PROVIDER_NAME_ATTR = 'weflayr.provider_name';
|
|
28
28
|
const UUID_ATTR = 'weflayr.uuid';
|
|
29
29
|
// Prefix for user-supplied `extraTags` so downstream normalizers can pick
|
|
@@ -36,10 +36,10 @@ function getMergedMetadataFromContext(parentContext) {
|
|
|
36
36
|
return existing && typeof existing === 'object' ? { ...existing } : {};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function _buildAttributes({ featureName,
|
|
39
|
+
function _buildAttributes({ featureName, customerName, providerName, extraTags, spanUuid }) {
|
|
40
40
|
const attributes = {
|
|
41
41
|
[FEATURE_NAME_ATTR]: featureName,
|
|
42
|
-
[
|
|
42
|
+
[CUSTOMER_NAME_ATTR]: customerName,
|
|
43
43
|
[UUID_ATTR]: spanUuid,
|
|
44
44
|
};
|
|
45
45
|
for (const [key, value] of Object.entries(extraTags)) {
|
|
@@ -63,25 +63,34 @@ function _buildAttributes({ featureName, customerId, providerName, extraTags, sp
|
|
|
63
63
|
*/
|
|
64
64
|
function propagateMetadata(options, fn) {
|
|
65
65
|
const { featureName, customerId } = options ?? {};
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
let { customerName } = options ?? {};
|
|
67
|
+
if (customerId != null) {
|
|
68
|
+
process.emitWarning(
|
|
69
|
+
'propagateMetadata: `customerId` is deprecated, use `customerName`',
|
|
70
|
+
'DeprecationWarning'
|
|
71
|
+
);
|
|
72
|
+
customerName = customerName ?? customerId;
|
|
68
73
|
}
|
|
74
|
+
if (featureName == null || customerName == null) {
|
|
75
|
+
throw new Error('propagateMetadata: `featureName` and `customerName` are required');
|
|
76
|
+
}
|
|
77
|
+
const normalizedOptions = { ...options, customerName };
|
|
69
78
|
|
|
70
79
|
if (typeof fn === 'function') {
|
|
71
|
-
return _runWithMetadata(
|
|
80
|
+
return _runWithMetadata(normalizedOptions, fn);
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
return function decorator(target, key, descriptor) {
|
|
75
84
|
if (descriptor && typeof descriptor.value === 'function') {
|
|
76
85
|
const original = descriptor.value;
|
|
77
86
|
descriptor.value = function wrapped(...args) {
|
|
78
|
-
return _runWithMetadata(
|
|
87
|
+
return _runWithMetadata(normalizedOptions, () => original.apply(this, args));
|
|
79
88
|
};
|
|
80
89
|
return descriptor;
|
|
81
90
|
}
|
|
82
91
|
if (typeof target === 'function') {
|
|
83
92
|
return function wrapped(...args) {
|
|
84
|
-
return _runWithMetadata(
|
|
93
|
+
return _runWithMetadata(normalizedOptions, () => target.apply(this, args));
|
|
85
94
|
};
|
|
86
95
|
}
|
|
87
96
|
return target;
|
|
@@ -89,10 +98,10 @@ function propagateMetadata(options, fn) {
|
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
function _runWithMetadata(options, fn) {
|
|
92
|
-
const { featureName,
|
|
101
|
+
const { featureName, customerName, providerName, extraTags } = options;
|
|
93
102
|
const attributes = _buildAttributes({
|
|
94
103
|
featureName,
|
|
95
|
-
|
|
104
|
+
customerName,
|
|
96
105
|
providerName,
|
|
97
106
|
extraTags: extraTags ?? {},
|
|
98
107
|
spanUuid: randomUUID().replace(/-/g, ''),
|
|
@@ -117,7 +126,7 @@ module.exports = {
|
|
|
117
126
|
getMergedMetadataFromContext,
|
|
118
127
|
WEFLAYR_METADATA_CONTEXT_KEY,
|
|
119
128
|
FEATURE_NAME_ATTR,
|
|
120
|
-
|
|
129
|
+
CUSTOMER_NAME_ATTR,
|
|
121
130
|
PROVIDER_NAME_ATTR,
|
|
122
131
|
UUID_ATTR,
|
|
123
132
|
EXTRA_TAG_PREFIX,
|
|
@@ -20,8 +20,35 @@ const { SpanStatusCode } = require('@opentelemetry/api');
|
|
|
20
20
|
const { BatchSpanProcessor, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
|
|
21
21
|
const { EXTRA_TAG_PREFIX, getMergedMetadataFromContext } = require('./propagation');
|
|
22
22
|
const { isDefaultExportSpan, isKnownLLMInstrumentor } = require('./span-filter');
|
|
23
|
+
const {
|
|
24
|
+
VercelAiSdkSpanFilter,
|
|
25
|
+
stampVercelGatewayProviderName,
|
|
26
|
+
} = require('./vercel-ai-sdk-span-filter');
|
|
23
27
|
const { debug } = require('../logger');
|
|
24
28
|
|
|
29
|
+
// setup({captureMessageContent: ...}) records the choice in this env var (the
|
|
30
|
+
// official OTel GenAI one). Instrumentors gate content on their own knobs
|
|
31
|
+
// (openllmetry reads its own config and captures by default), so the processor
|
|
32
|
+
// enforces the flag itself: when the env var is "false", message content is
|
|
33
|
+
// stripped from every span before export.
|
|
34
|
+
const CONTENT_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
|
|
35
|
+
|
|
36
|
+
// The new-semconv JSON message attributes and the legacy indexed
|
|
37
|
+
// `gen_ai.prompt.{i}.*` / `gen_ai.completion.{i}.*` attributes.
|
|
38
|
+
const CONTENT_ATTRIBUTES = [
|
|
39
|
+
'gen_ai.input.messages',
|
|
40
|
+
'gen_ai.output.messages',
|
|
41
|
+
'gen_ai.system_instructions',
|
|
42
|
+
];
|
|
43
|
+
const CONTENT_ATTRIBUTE_PREFIXES = ['gen_ai.prompt.', 'gen_ai.completion.'];
|
|
44
|
+
|
|
45
|
+
function _isContentAttribute(key) {
|
|
46
|
+
return (
|
|
47
|
+
CONTENT_ATTRIBUTES.includes(key) ||
|
|
48
|
+
CONTENT_ATTRIBUTE_PREFIXES.some((prefix) => key.startsWith(prefix))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
class WeflayrSpanProcessor {
|
|
26
53
|
constructor({ exporter, exportMode = 'batch', defaultTags = {}, mask, shouldExportSpan } = {}) {
|
|
27
54
|
if (!exporter) {
|
|
@@ -34,6 +61,7 @@ class WeflayrSpanProcessor {
|
|
|
34
61
|
this._defaultTags = defaultTags;
|
|
35
62
|
this._mask = mask;
|
|
36
63
|
this._shouldExportSpan = shouldExportSpan ?? isDefaultExportSpan;
|
|
64
|
+
this._vercelAiSdkSpanFilter = new VercelAiSdkSpanFilter();
|
|
37
65
|
// LLM spans we've started but not yet seen end. On forceFlush we end any
|
|
38
66
|
// leftovers with ERROR status — covers cases like openllmetry's anthropic
|
|
39
67
|
// streaming wrapper, which leaks the span when the underlying call
|
|
@@ -52,6 +80,7 @@ class WeflayrSpanProcessor {
|
|
|
52
80
|
if (isKnownLLMInstrumentor(span)) {
|
|
53
81
|
this._openLlmSpans.add(span);
|
|
54
82
|
}
|
|
83
|
+
this._vercelAiSdkSpanFilter.onStart(span);
|
|
55
84
|
debug(
|
|
56
85
|
'span start: name=%s defaultTags=%d propagatedMetadata=%d',
|
|
57
86
|
span.name,
|
|
@@ -64,7 +93,11 @@ class WeflayrSpanProcessor {
|
|
|
64
93
|
onEnd(span) {
|
|
65
94
|
this._openLlmSpans.delete(span);
|
|
66
95
|
try {
|
|
67
|
-
|
|
96
|
+
const vercelVerdict = this._vercelAiSdkSpanFilter.shouldExport(span);
|
|
97
|
+
if (vercelVerdict === true) {
|
|
98
|
+
stampVercelGatewayProviderName(span);
|
|
99
|
+
}
|
|
100
|
+
if (vercelVerdict === false || (vercelVerdict === null && !this._shouldExportSpan(span))) {
|
|
68
101
|
debug(
|
|
69
102
|
'span end: filtered out name=%s scope=%s',
|
|
70
103
|
span.name,
|
|
@@ -77,6 +110,12 @@ class WeflayrSpanProcessor {
|
|
|
77
110
|
}
|
|
78
111
|
debug('span end: exporting name=%s scope=%s', span.name, span.instrumentationScope?.name);
|
|
79
112
|
|
|
113
|
+
if ((process.env[CONTENT_CAPTURE_ENV] || '').toLowerCase() === 'false') {
|
|
114
|
+
for (const key of Object.keys(span.attributes)) {
|
|
115
|
+
if (_isContentAttribute(key)) delete span.attributes[key];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
if (this._mask) {
|
|
81
120
|
try {
|
|
82
121
|
const masked = this._mask(span.attributes);
|
|
@@ -120,4 +159,4 @@ class WeflayrSpanProcessor {
|
|
|
120
159
|
}
|
|
121
160
|
}
|
|
122
161
|
|
|
123
|
-
module.exports = { WeflayrSpanProcessor };
|
|
162
|
+
module.exports = { CONTENT_CAPTURE_ENV, WeflayrSpanProcessor };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Vercel AI SDK's OTel integrations emit aggregate spans around each model
|
|
4
|
+
// call, all repeating the same usage counts:
|
|
5
|
+
// - `@ai-sdk/otel` (ai >= 7, scope "gen_ai"): per-call "chat" spans are
|
|
6
|
+
// wrapped in "agent_step" + "invoke_agent" spans, and embed/embedMany
|
|
7
|
+
// emits an outer "embeddings" operation span around the model-call
|
|
8
|
+
// "embeddings" span(s).
|
|
9
|
+
// - the legacy integration (ai <= 6, scope "ai"): `ai.<fn>.do<Call>` spans
|
|
10
|
+
// are wrapped in an outer `ai.<fn>` span.
|
|
11
|
+
// Exactly one span per model call may reach the exporter — exporting an
|
|
12
|
+
// aggregate alongside its children would bill every call twice.
|
|
13
|
+
|
|
14
|
+
const VERCEL_SEMCONV_SCOPE = 'gen_ai';
|
|
15
|
+
const VERCEL_LEGACY_SCOPE = 'ai';
|
|
16
|
+
|
|
17
|
+
const LEGACY_MODEL_CALL_SPAN_NAME = /\.do(Generate|Stream|Embed)$/;
|
|
18
|
+
|
|
19
|
+
function _parentSpanId(span) {
|
|
20
|
+
return span.parentSpanContext?.spanId ?? span.parentSpanId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class VercelAiSdkSpanFilter {
|
|
24
|
+
constructor() {
|
|
25
|
+
// spanId -> gen_ai.operation.name for open "gen_ai"-scope spans, so a
|
|
26
|
+
// span can check what its parent is (children end before their parent).
|
|
27
|
+
this._openSpanOperations = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onStart(span) {
|
|
31
|
+
if (span.instrumentationScope?.name !== VERCEL_SEMCONV_SCOPE) return;
|
|
32
|
+
this._openSpanOperations.set(
|
|
33
|
+
span.spanContext().spanId,
|
|
34
|
+
span.attributes['gen_ai.operation.name']
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Export verdict for a Vercel AI SDK span, or null when the span is not
|
|
40
|
+
* one (the caller falls back to the default filter).
|
|
41
|
+
*/
|
|
42
|
+
shouldExport(span) {
|
|
43
|
+
const scope = span.instrumentationScope?.name;
|
|
44
|
+
if (scope === VERCEL_LEGACY_SCOPE) {
|
|
45
|
+
return LEGACY_MODEL_CALL_SPAN_NAME.test(span.name);
|
|
46
|
+
}
|
|
47
|
+
if (scope !== VERCEL_SEMCONV_SCOPE) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const operation = span.attributes['gen_ai.operation.name'];
|
|
51
|
+
this._openSpanOperations.delete(span.spanContext().spanId);
|
|
52
|
+
if (operation === 'chat') {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (operation === 'embeddings') {
|
|
56
|
+
// Keep the outer aggregate (one event per embed/embedMany call, with the
|
|
57
|
+
// total usage) and drop the nested model-call span(s) repeating it.
|
|
58
|
+
return this._openSpanOperations.get(_parentSpanId(span)) !== 'embeddings';
|
|
59
|
+
}
|
|
60
|
+
// invoke_agent / agent_step (usage aggregates) and execute_tool (no usage).
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `@ai-sdk/otel` reports the Vercel AI Gateway as the bare provider id
|
|
66
|
+
// "gateway" — too generic a name to special-case downstream. Stamp the weflayr
|
|
67
|
+
// provider override (which the normalizer prefers) with the canonical name.
|
|
68
|
+
// A providerName set through propagateMetadata was stamped at span start and
|
|
69
|
+
// wins.
|
|
70
|
+
function stampVercelGatewayProviderName(span) {
|
|
71
|
+
if (
|
|
72
|
+
span.attributes['gen_ai.provider.name'] === 'gateway' &&
|
|
73
|
+
span.attributes['weflayr.provider_name'] === undefined
|
|
74
|
+
) {
|
|
75
|
+
span.attributes['weflayr.provider_name'] = 'vercel_ai_gateway';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { VercelAiSdkSpanFilter, stampVercelGatewayProviderName };
|
package/src/setup.js
CHANGED
|
@@ -5,13 +5,12 @@ const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
|
|
|
5
5
|
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
|
6
6
|
|
|
7
7
|
const { buildExporter } = require('./otel/exporter');
|
|
8
|
-
const { WeflayrSpanProcessor } = require('./otel/span-processor');
|
|
8
|
+
const { CONTENT_CAPTURE_ENV, WeflayrSpanProcessor } = require('./otel/span-processor');
|
|
9
9
|
const { debug } = require('./logger');
|
|
10
10
|
const { version: SDK_VERSION } = require('../package.json');
|
|
11
11
|
|
|
12
12
|
const SDK_LANGUAGE = 'javascript';
|
|
13
13
|
const DEFAULT_BASE_URL = 'https://api.weflayr.com';
|
|
14
|
-
const CONTENT_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
|
|
15
14
|
|
|
16
15
|
const _state = {
|
|
17
16
|
tracerProvider: null,
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the public-API client: auth and base-URL wiring of the
|
|
4
|
+
// client facade, plus the request/response wiring of every generated
|
|
5
|
+
// endpoint (server behavior is covered by the dashboard's app_api tests).
|
|
6
|
+
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const http = require('node:http');
|
|
10
|
+
|
|
11
|
+
const weflayr = require('../../src/index');
|
|
12
|
+
const { api } = weflayr;
|
|
13
|
+
|
|
14
|
+
// A client whose server records requests and returns a canned response.
|
|
15
|
+
async function withClientReturning(responseBody, statusCode, run, clientOptions = {}) {
|
|
16
|
+
const seenRequests = [];
|
|
17
|
+
const server = http.createServer((request, response) => {
|
|
18
|
+
let requestBody = '';
|
|
19
|
+
request.on('data', (chunk) => {
|
|
20
|
+
requestBody += chunk;
|
|
21
|
+
});
|
|
22
|
+
request.on('end', () => {
|
|
23
|
+
seenRequests.push({
|
|
24
|
+
method: request.method,
|
|
25
|
+
url: request.url,
|
|
26
|
+
authorization: request.headers.authorization,
|
|
27
|
+
body: requestBody === '' ? null : JSON.parse(requestBody),
|
|
28
|
+
});
|
|
29
|
+
response.statusCode = statusCode;
|
|
30
|
+
response.setHeader('content-type', 'application/json');
|
|
31
|
+
response.end(JSON.stringify(responseBody));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
35
|
+
const client = api.client({
|
|
36
|
+
apiKey: 'wf-test-key',
|
|
37
|
+
...clientOptions,
|
|
38
|
+
baseUrl: `http://127.0.0.1:${server.address().port}/api`,
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
await run(client, seenRequests);
|
|
42
|
+
} finally {
|
|
43
|
+
server.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── client facade ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('client sends the bearer key to the configured host', async () => {
|
|
50
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
51
|
+
const { data } = await api.getAllRevenue({ client, throwOnError: true });
|
|
52
|
+
assert.deepEqual(data, { rows: [] });
|
|
53
|
+
assert.deepEqual(seenRequests, [
|
|
54
|
+
{ method: 'GET', url: '/api/revenue/', authorization: 'Bearer wf-test-key', body: null },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('weflayr.client is the same function as api.client', () => {
|
|
60
|
+
assert.equal(weflayr.client, api.client);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('client defaults to the production API host', () => {
|
|
64
|
+
const client = api.client({ apiKey: 'wf-test-key' });
|
|
65
|
+
assert.equal(client.getConfig().baseUrl, 'https://app.weflayr.com/api');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('client throws without apiKey', () => {
|
|
69
|
+
const saved = process.env.WEFLAYR_API_KEY;
|
|
70
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
71
|
+
try {
|
|
72
|
+
assert.throws(() => api.client(), /apiKey/);
|
|
73
|
+
} finally {
|
|
74
|
+
if (saved !== undefined) process.env.WEFLAYR_API_KEY = saved;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('client picks up apiKey from WEFLAYR_API_KEY env var', async () => {
|
|
79
|
+
process.env.WEFLAYR_API_KEY = 'env-key';
|
|
80
|
+
try {
|
|
81
|
+
await withClientReturning(
|
|
82
|
+
{ rows: [] },
|
|
83
|
+
200,
|
|
84
|
+
async (client, seenRequests) => {
|
|
85
|
+
await api.getAllRevenue({ client, throwOnError: true });
|
|
86
|
+
assert.equal(seenRequests[0].authorization, 'Bearer env-key');
|
|
87
|
+
},
|
|
88
|
+
{ apiKey: undefined }
|
|
89
|
+
);
|
|
90
|
+
} finally {
|
|
91
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Revenue ───────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
test('setRevenue posts the rows in snake_case and parses the booked count', async () => {
|
|
98
|
+
await withClientReturning({ ok: true, rows_inserted: 2 }, 200, async (client, seenRequests) => {
|
|
99
|
+
const rows = [
|
|
100
|
+
{ customerName: 'c_1', amount: '1200.50', month: '2026-06' },
|
|
101
|
+
{ customerName: 'c_2', amount: '800', month: '2026-06' },
|
|
102
|
+
];
|
|
103
|
+
const { data } = await api.setRevenue({ client, throwOnError: true, body: { rows } });
|
|
104
|
+
assert.deepEqual(data, { ok: true, rowsInserted: 2 });
|
|
105
|
+
const request = seenRequests[0];
|
|
106
|
+
assert.equal(request.method, 'POST');
|
|
107
|
+
assert.equal(request.url, '/api/revenue/');
|
|
108
|
+
assert.deepEqual(request.body, {
|
|
109
|
+
rows: [
|
|
110
|
+
{ customer_name: 'c_1', amount: '1200.50', month: '2026-06' },
|
|
111
|
+
{ customer_name: 'c_2', amount: '800', month: '2026-06' },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('getAllRevenue parses the returned rows into camelCase', async () => {
|
|
118
|
+
const wireRow = {
|
|
119
|
+
customer_name: 'c_1',
|
|
120
|
+
metric_name: 'revenue',
|
|
121
|
+
month: '2026-06',
|
|
122
|
+
amount: '1200.50000000',
|
|
123
|
+
source: 'api',
|
|
124
|
+
};
|
|
125
|
+
await withClientReturning({ rows: [wireRow] }, 200, async (client) => {
|
|
126
|
+
const { data } = await api.getAllRevenue({ client, throwOnError: true });
|
|
127
|
+
assert.deepEqual(data.rows, [
|
|
128
|
+
{
|
|
129
|
+
customerName: 'c_1',
|
|
130
|
+
metricName: 'revenue',
|
|
131
|
+
month: '2026-06',
|
|
132
|
+
amount: '1200.50000000',
|
|
133
|
+
source: 'api',
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('getCustomerRevenue requests the customer path', async () => {
|
|
140
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
141
|
+
const { data } = await api.getCustomerRevenue({
|
|
142
|
+
client,
|
|
143
|
+
throwOnError: true,
|
|
144
|
+
path: { customerName: 'c_1' },
|
|
145
|
+
});
|
|
146
|
+
assert.deepEqual(data, { rows: [] });
|
|
147
|
+
assert.equal(seenRequests[0].url, '/api/revenue/c_1/');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('getCustomerRevenue surfaces a 404 and its error', async () => {
|
|
152
|
+
const rejection = { error: 'No revenue for this customer.' };
|
|
153
|
+
await withClientReturning(rejection, 404, async (client) => {
|
|
154
|
+
const { error, response } = await api.getCustomerRevenue({
|
|
155
|
+
client,
|
|
156
|
+
path: { customerName: 'nobody' },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(response.status, 404);
|
|
159
|
+
assert.deepEqual(error, rejection);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('deleteCustomerRevenue parses the deleted count', async () => {
|
|
164
|
+
await withClientReturning({ ok: true, rows_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
165
|
+
const { data } = await api.deleteCustomerRevenue({
|
|
166
|
+
client,
|
|
167
|
+
throwOnError: true,
|
|
168
|
+
path: { customerName: 'c_1' },
|
|
169
|
+
});
|
|
170
|
+
assert.deepEqual(data, { ok: true, rowsDeleted: 1 });
|
|
171
|
+
const request = seenRequests[0];
|
|
172
|
+
assert.equal(request.method, 'DELETE');
|
|
173
|
+
assert.equal(request.url, '/api/revenue/c_1/');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Key metrics ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
test('setKeyMetrics posts the rows in snake_case and parses the booked count', async () => {
|
|
180
|
+
await withClientReturning({ ok: true, rows_inserted: 1 }, 200, async (client, seenRequests) => {
|
|
181
|
+
const rows = [
|
|
182
|
+
{ userName: 'u_1', metricName: 'Meetings booked', amount: '3', month: '2026-06' },
|
|
183
|
+
];
|
|
184
|
+
const { data } = await api.setKeyMetrics({ client, throwOnError: true, body: { rows } });
|
|
185
|
+
assert.deepEqual(data, { ok: true, rowsInserted: 1 });
|
|
186
|
+
const request = seenRequests[0];
|
|
187
|
+
assert.equal(request.method, 'POST');
|
|
188
|
+
assert.equal(request.url, '/api/key-metrics/');
|
|
189
|
+
assert.deepEqual(request.body, {
|
|
190
|
+
rows: [{ user_name: 'u_1', metric_name: 'Meetings booked', amount: '3', month: '2026-06' }],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('getAllKeyMetrics parses the returned rows into camelCase', async () => {
|
|
196
|
+
const wireRow = {
|
|
197
|
+
user_name: 'u_1',
|
|
198
|
+
metric_name: 'Meetings booked',
|
|
199
|
+
month: '2026-06',
|
|
200
|
+
amount: '3.00000000',
|
|
201
|
+
source: 'api',
|
|
202
|
+
};
|
|
203
|
+
await withClientReturning({ rows: [wireRow] }, 200, async (client) => {
|
|
204
|
+
const { data } = await api.getAllKeyMetrics({ client, throwOnError: true });
|
|
205
|
+
assert.deepEqual(data.rows, [
|
|
206
|
+
{
|
|
207
|
+
userName: 'u_1',
|
|
208
|
+
metricName: 'Meetings booked',
|
|
209
|
+
month: '2026-06',
|
|
210
|
+
amount: '3.00000000',
|
|
211
|
+
source: 'api',
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('getUserKeyMetrics requests the user path', async () => {
|
|
218
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
219
|
+
const { data } = await api.getUserKeyMetrics({
|
|
220
|
+
client,
|
|
221
|
+
throwOnError: true,
|
|
222
|
+
path: { userName: 'u_1' },
|
|
223
|
+
});
|
|
224
|
+
assert.deepEqual(data, { rows: [] });
|
|
225
|
+
assert.equal(seenRequests[0].url, '/api/key-metrics/u_1/');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('setKeyMetrics surfaces a 422 and its details', async () => {
|
|
230
|
+
const rejection = {
|
|
231
|
+
error: 'Invalid payload.',
|
|
232
|
+
detail: [{ field: 'rows.0.metric_name', error: 'Unknown metric.' }],
|
|
233
|
+
};
|
|
234
|
+
await withClientReturning(rejection, 422, async (client) => {
|
|
235
|
+
const { error, response } = await api.setKeyMetrics({ client, body: { rows: [] } });
|
|
236
|
+
assert.equal(response.status, 422);
|
|
237
|
+
assert.deepEqual(error, rejection);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('deleteUserKeyMetrics parses the deleted count', async () => {
|
|
242
|
+
await withClientReturning({ ok: true, rows_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
243
|
+
const { data } = await api.deleteUserKeyMetrics({
|
|
244
|
+
client,
|
|
245
|
+
throwOnError: true,
|
|
246
|
+
path: { userName: 'u_1' },
|
|
247
|
+
});
|
|
248
|
+
assert.deepEqual(data, { ok: true, rowsDeleted: 1 });
|
|
249
|
+
const request = seenRequests[0];
|
|
250
|
+
assert.equal(request.method, 'DELETE');
|
|
251
|
+
assert.equal(request.url, '/api/key-metrics/u_1/');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Customer tags ─────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
test('setCustomersTags posts the customers in snake_case and parses the updated count', async () => {
|
|
258
|
+
await withClientReturning(
|
|
259
|
+
{ ok: true, customers_updated: 1 },
|
|
260
|
+
200,
|
|
261
|
+
async (client, seenRequests) => {
|
|
262
|
+
const customers = [{ customerName: 'c_1', tags: { plan: 'pro' } }];
|
|
263
|
+
const { data } = await api.setCustomersTags({
|
|
264
|
+
client,
|
|
265
|
+
throwOnError: true,
|
|
266
|
+
body: { customers },
|
|
267
|
+
});
|
|
268
|
+
assert.deepEqual(data, { ok: true, customersUpdated: 1 });
|
|
269
|
+
const request = seenRequests[0];
|
|
270
|
+
assert.equal(request.method, 'POST');
|
|
271
|
+
assert.equal(request.url, '/api/customer-tags/');
|
|
272
|
+
assert.deepEqual(request.body, {
|
|
273
|
+
customers: [{ customer_name: 'c_1', tags: { plan: 'pro' } }],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('setCustomersTags leaves tag keys untouched, even when they contain underscores', async () => {
|
|
280
|
+
await withClientReturning(
|
|
281
|
+
{ ok: true, customers_updated: 1 },
|
|
282
|
+
200,
|
|
283
|
+
async (client, seenRequests) => {
|
|
284
|
+
const customers = [{ customerName: 'c_1', tags: { utm_source: 'ads', plan_tier: 'pro' } }];
|
|
285
|
+
await api.setCustomersTags({ client, throwOnError: true, body: { customers } });
|
|
286
|
+
assert.deepEqual(seenRequests[0].body, {
|
|
287
|
+
customers: [{ customer_name: 'c_1', tags: { utm_source: 'ads', plan_tier: 'pro' } }],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('getAllCustomersTags parses the returned customers into camelCase', async () => {
|
|
294
|
+
const wireCustomer = { customer_name: 'c_1', tags: { plan: 'pro' } };
|
|
295
|
+
await withClientReturning(
|
|
296
|
+
{ customers: [wireCustomer], next_cursor: null, has_more: false },
|
|
297
|
+
200,
|
|
298
|
+
async (client) => {
|
|
299
|
+
const { data } = await api.getAllCustomersTags({ client, throwOnError: true });
|
|
300
|
+
assert.deepEqual(data.customers, [{ customerName: 'c_1', tags: { plan: 'pro' } }]);
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('getCustomerTags parses the customer tags, leaving tag keys untouched', async () => {
|
|
306
|
+
const wireTags = { customer_name: 'c_1', tags: { plan: 'pro', utm_source: 'ads' } };
|
|
307
|
+
await withClientReturning(wireTags, 200, async (client, seenRequests) => {
|
|
308
|
+
const { data } = await api.getCustomerTags({
|
|
309
|
+
client,
|
|
310
|
+
throwOnError: true,
|
|
311
|
+
path: { customerName: 'c_1' },
|
|
312
|
+
});
|
|
313
|
+
assert.deepEqual(data, { customerName: 'c_1', tags: { plan: 'pro', utm_source: 'ads' } });
|
|
314
|
+
assert.equal(seenRequests[0].url, '/api/customer-tags/c_1/');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('deleteCustomerTags parses the deleted count', async () => {
|
|
319
|
+
await withClientReturning({ ok: true, tags_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
320
|
+
const { data } = await api.deleteCustomerTags({
|
|
321
|
+
client,
|
|
322
|
+
throwOnError: true,
|
|
323
|
+
path: { customerName: 'c_1' },
|
|
324
|
+
});
|
|
325
|
+
assert.deepEqual(data, { ok: true, tagsDeleted: 1 });
|
|
326
|
+
const request = seenRequests[0];
|
|
327
|
+
assert.equal(request.method, 'DELETE');
|
|
328
|
+
assert.equal(request.url, '/api/customer-tags/c_1/');
|
|
329
|
+
});
|
|
330
|
+
});
|