weflayr 0.23.0 → 0.24.0
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/dist/api/generated/facade.gen.d.ts +77 -0
- package/dist/api/generated/facade.gen.js +7 -1
- package/dist/api/generated/index.d.ts +2 -2
- package/dist/api/generated/index.js +7 -1
- package/dist/api/generated/sdk.gen.d.ts +25 -1
- package/dist/api/generated/sdk.gen.js +55 -1
- package/dist/api/generated/types.gen.d.ts +256 -0
- package/index.d.ts +5 -2
- package/package.json +1 -1
- package/src/api/generated/facade.gen.ts +36 -0
- package/src/api/generated/index.ts +2 -2
- package/src/api/generated/sdk.gen.ts +55 -1
- package/src/api/generated/types.gen.ts +293 -0
- package/src/auto-instrument.js +1 -0
- package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +11 -0
- package/src/instrumentation/anthropic/get_cache_config_telemetry.js +90 -0
- package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +6 -1
- package/src/instrumentation/anthropic/set_telemetry_attributes.js +2 -0
- package/src/instrumentation/bedrock/get_cache_config_telemetry.js +106 -0
- package/src/instrumentation/bedrock/get_cache_telemetry.js +11 -6
- package/src/instrumentation/bedrock/set_telemetry_attributes.js +6 -2
- package/src/instrumentation/google/get_cache_operations_telemetry.js +171 -0
- package/src/instrumentation/google/get_cache_telemetry.js +16 -2
- package/src/instrumentation/google/set_telemetry_attributes.js +8 -2
- package/src/instrumentation/openai/get_cache_config_telemetry.js +63 -0
- package/src/instrumentation/openai/set_telemetry_attributes.js +2 -0
- package/src/instrumentation/vercel/full_instrumentation.js +10 -2
- package/src/instrumentation/vercel/get_cache_creation_telemetry.js +70 -0
- package/src/otel/propagation.js +22 -9
- package/src/otel/span-filter.js +12 -12
- package/src/otel/span-processor.js +8 -0
- package/src/otel/vercel-ai-sdk-span-filter.js +1 -1
- package/tests/index.test.js +67 -7
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +23 -0
- package/tests/instrumentation/anthropic/get_cache_config_telemetry.test.js +92 -0
- package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +15 -0
- package/tests/instrumentation/bedrock/get_cache_config_telemetry.test.js +107 -0
- package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +16 -0
- package/tests/instrumentation/google/get_cache_operations_telemetry.test.js +130 -0
- package/tests/instrumentation/openai/get_cache_config_telemetry.test.js +83 -0
- package/tests/instrumentation/vercel/get_cache_creation_telemetry.test.js +96 -0
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Wires the weflayr-supplied patches the traceloop Bedrock instrumentor misses:
|
|
7
7
|
* cached-prompt token counts on the invoke_model span (Nova returns them in the
|
|
8
|
-
* response body but traceloop never reads them)
|
|
9
|
-
*
|
|
8
|
+
* response body but traceloop never reads them), the request's cache
|
|
9
|
+
* breakpoints (`cache_control` / `cachePoint` blocks it never records) and the
|
|
10
|
+
* streamed output message content (its stream path formats an empty-parts
|
|
11
|
+
* message).
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
const { BedrockCacheConfigInstrumentor } = require('./get_cache_config_telemetry');
|
|
12
15
|
const { BedrockPromptCachingInstrumentor } = require('./get_cache_telemetry');
|
|
13
16
|
const { BedrockInputMessageContentInstrumentor } = require('./get_input_message_content_telemetry');
|
|
14
17
|
const {
|
|
@@ -19,6 +22,7 @@ class BedrockTelemetryInstrumentor {
|
|
|
19
22
|
constructor() {
|
|
20
23
|
this._subInstrumentors = [
|
|
21
24
|
new BedrockPromptCachingInstrumentor(),
|
|
25
|
+
new BedrockCacheConfigInstrumentor(),
|
|
22
26
|
new BedrockInputMessageContentInstrumentor(),
|
|
23
27
|
new BedrockStreamingOutputMessageInstrumentor(),
|
|
24
28
|
];
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Explicit-cache lifecycle telemetry for Google GenAI (Gemini).
|
|
5
|
+
*
|
|
6
|
+
* Explicit caching happens through API calls that are not LLM calls — the
|
|
7
|
+
* caller uploads content once via `client.caches.create` (returning a
|
|
8
|
+
* `cachedContents/{id}` name later referenced by `generateContent`), and may
|
|
9
|
+
* extend its TTL (`caches.update`) or remove it (`caches.delete`). No
|
|
10
|
+
* instrumentor covers these endpoints, so the cached content and its lifetime
|
|
11
|
+
* were invisible to the telemetry.
|
|
12
|
+
*
|
|
13
|
+
* One span per lifecycle call, carrying:
|
|
14
|
+
* - `weflayr.cache.operation` — create / update / delete
|
|
15
|
+
* - `weflayr.cache.cached_content_id` — the cache's server-generated name
|
|
16
|
+
* - `weflayr.cache.expire_time` — absolute expiry (create / update)
|
|
17
|
+
* - `gen_ai.usage.input_tokens` — the cached content's token count
|
|
18
|
+
* (create only; billed at the standard input rate)
|
|
19
|
+
* - the cached content itself (`gen_ai.input.messages` /
|
|
20
|
+
* `gen_ai.system_instructions` / `gen_ai.tool.definitions`) — the content
|
|
21
|
+
* is sent only on the create call and never repeated in the calls that use
|
|
22
|
+
* the cache, so this is the one place it can be captured. The span
|
|
23
|
+
* processor strips these when message-content capture is disabled.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { trace, SpanStatusCode } = require('@opentelemetry/api');
|
|
27
|
+
|
|
28
|
+
const { getProviderName } = require('./get_provider_name');
|
|
29
|
+
|
|
30
|
+
const TRACER_NAME = 'weflayr.instrumentation.google.cache_operations';
|
|
31
|
+
|
|
32
|
+
const OPERATION_ATTR = 'weflayr.cache.operation';
|
|
33
|
+
const CACHED_CONTENT_ID_ATTR = 'weflayr.cache.cached_content_id';
|
|
34
|
+
const EXPIRE_TIME_ATTR = 'weflayr.cache.expire_time';
|
|
35
|
+
|
|
36
|
+
const OPERATIONS = ['create', 'update', 'delete'];
|
|
37
|
+
|
|
38
|
+
// The cached content mapped to the role+parts message shape the chat path uses.
|
|
39
|
+
function _contentToMessage(content) {
|
|
40
|
+
if (typeof content === 'string') {
|
|
41
|
+
return { role: 'user', parts: [{ type: 'text', content }] };
|
|
42
|
+
}
|
|
43
|
+
const role = (content && content.role) || 'user';
|
|
44
|
+
const rawParts = (content && content.parts) || [];
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (const rawPart of rawParts) {
|
|
47
|
+
if (typeof rawPart === 'string') {
|
|
48
|
+
parts.push({ type: 'text', content: rawPart });
|
|
49
|
+
} else if (rawPart && typeof rawPart.text === 'string') {
|
|
50
|
+
parts.push({ type: 'text', content: rawPart.text });
|
|
51
|
+
} else if (rawPart && rawPart.inlineData) {
|
|
52
|
+
// Non-text parts (e.g. audio) become a byte-less blob stub so span
|
|
53
|
+
// attributes never carry raw media payloads.
|
|
54
|
+
parts.push({ type: 'blob', mime_type: rawPart.inlineData.mimeType || '' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { role, parts };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _setSystemInstruction(span, systemInstruction) {
|
|
61
|
+
if (systemInstruction == null) return;
|
|
62
|
+
const texts =
|
|
63
|
+
typeof systemInstruction === 'string'
|
|
64
|
+
? [systemInstruction]
|
|
65
|
+
: (systemInstruction.parts || [])
|
|
66
|
+
.map((part) => part && part.text)
|
|
67
|
+
.filter((text) => typeof text === 'string');
|
|
68
|
+
if (texts.length) {
|
|
69
|
+
span.setAttribute(
|
|
70
|
+
'gen_ai.system_instructions',
|
|
71
|
+
JSON.stringify(texts.map((text) => ({ type: 'text', content: text })))
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _setToolDefinitions(span, config) {
|
|
77
|
+
const definitions = [];
|
|
78
|
+
for (const tool of (config && config.tools) || []) {
|
|
79
|
+
for (const declaration of (tool && tool.functionDeclarations) || []) {
|
|
80
|
+
definitions.push(declaration);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (definitions.length) {
|
|
84
|
+
span.setAttribute('gen_ai.tool.definitions', JSON.stringify(definitions));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stamp the server-side facts of the cache from a create/update response.
|
|
89
|
+
function _stampCachedContent(span, cachedContent, operation) {
|
|
90
|
+
if (!cachedContent) return;
|
|
91
|
+
if (cachedContent.name) {
|
|
92
|
+
span.setAttribute(CACHED_CONTENT_ID_ATTR, String(cachedContent.name));
|
|
93
|
+
}
|
|
94
|
+
if (cachedContent.expireTime) {
|
|
95
|
+
span.setAttribute(EXPIRE_TIME_ATTR, String(cachedContent.expireTime));
|
|
96
|
+
}
|
|
97
|
+
// Gemini bills the cached tokens once, at creation; an update (TTL extension)
|
|
98
|
+
// returns the same usageMetadata but bills nothing.
|
|
99
|
+
if (operation !== 'create') return;
|
|
100
|
+
const totalTokens = cachedContent.usageMetadata && cachedContent.usageMetadata.totalTokenCount;
|
|
101
|
+
if (totalTokens) {
|
|
102
|
+
span.setAttribute('gen_ai.usage.input_tokens', totalTokens);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _startSpan(operation, params, system) {
|
|
107
|
+
const span = trace.getTracer(TRACER_NAME).startSpan(`cache_${operation}`);
|
|
108
|
+
span.setAttribute('gen_ai.system', system);
|
|
109
|
+
span.setAttribute('gen_ai.operation.name', `cache_${operation}`);
|
|
110
|
+
span.setAttribute(OPERATION_ATTR, operation);
|
|
111
|
+
if (params && params.model) {
|
|
112
|
+
// The chat path strips the leading "models/" prefix; mirror it.
|
|
113
|
+
span.setAttribute('gen_ai.request.model', params.model.replace(/^models\//, ''));
|
|
114
|
+
}
|
|
115
|
+
// update/delete address the cache by name; create gets it from the response.
|
|
116
|
+
if (params && params.name) {
|
|
117
|
+
span.setAttribute(CACHED_CONTENT_ID_ATTR, String(params.name));
|
|
118
|
+
}
|
|
119
|
+
if (operation === 'create') {
|
|
120
|
+
const config = (params && params.config) || {};
|
|
121
|
+
if (config.contents != null) {
|
|
122
|
+
const contentsList = Array.isArray(config.contents) ? config.contents : [config.contents];
|
|
123
|
+
span.setAttribute(
|
|
124
|
+
'gen_ai.input.messages',
|
|
125
|
+
JSON.stringify(contentsList.map(_contentToMessage))
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
_setSystemInstruction(span, config.systemInstruction);
|
|
129
|
+
_setToolDefinitions(span, config);
|
|
130
|
+
}
|
|
131
|
+
return span;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _recordError(span, error) {
|
|
135
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
|
|
136
|
+
span.recordException(error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Wrap `Caches.prototype.create/update/delete`, pushing the restore tuples onto
|
|
140
|
+
// the shared `patched` array the orchestrator owns for uninstrument().
|
|
141
|
+
function instrumentCacheOperations(Caches, patched) {
|
|
142
|
+
const owner = Caches.prototype;
|
|
143
|
+
for (const operation of OPERATIONS) {
|
|
144
|
+
const original = owner[operation];
|
|
145
|
+
if (typeof original !== 'function') continue;
|
|
146
|
+
|
|
147
|
+
const wrapper = function cacheOperationWrapper(params, ...rest) {
|
|
148
|
+
const span = _startSpan(operation, params, getProviderName(this));
|
|
149
|
+
return Promise.resolve(original.call(this, params, ...rest)).then(
|
|
150
|
+
(response) => {
|
|
151
|
+
try {
|
|
152
|
+
_stampCachedContent(span, response, operation);
|
|
153
|
+
} catch {
|
|
154
|
+
// Never break the caller over an enrichment failure.
|
|
155
|
+
}
|
|
156
|
+
span.end();
|
|
157
|
+
return response;
|
|
158
|
+
},
|
|
159
|
+
(error) => {
|
|
160
|
+
_recordError(span, error);
|
|
161
|
+
span.end();
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
owner[operation] = wrapper;
|
|
167
|
+
patched.push([owner, operation, original]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { instrumentCacheOperations };
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Context-caching
|
|
4
|
+
* Context-caching enrichment for the Google GenAI (Gemini) chat span.
|
|
5
5
|
*
|
|
6
6
|
* Neither instrumentor records cached-content tokens, so we read
|
|
7
7
|
* `usageMetadata.cachedContentTokenCount` (explicit or implicit caching) and
|
|
8
8
|
* split it by modality: audio cached tokens go to
|
|
9
9
|
* `gen_ai.usage.input_tokens_details.cached_audio_tokens`and the remainder to `gen_ai.usage.cache_read_input_tokens`
|
|
10
|
+
*
|
|
11
|
+
* Also captures the request's explicit-cache reference: a call using explicit
|
|
12
|
+
* caching passes `config.cachedContent` (the `cachedContents/{id}` name of a
|
|
13
|
+
* cache created via `client.caches.create`), stamped as
|
|
14
|
+
* `weflayr.cache.cached_content_id` so the call can be joined to its cache
|
|
15
|
+
* lifecycle rows.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
const { _audioTokens } = require('./get_audio_telemetry');
|
|
13
19
|
|
|
20
|
+
const CACHED_CONTENT_ID_ATTR = 'weflayr.cache.cached_content_id';
|
|
21
|
+
|
|
14
22
|
function setCacheTelemetry(span, response) {
|
|
15
23
|
const usage = response && response.usageMetadata;
|
|
16
24
|
const cachedTokens = (usage && usage.cachedContentTokenCount) || 0;
|
|
@@ -22,4 +30,10 @@ function setCacheTelemetry(span, response) {
|
|
|
22
30
|
if (cacheRead) span.setAttribute('gen_ai.usage.cache_read_input_tokens', cacheRead);
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
// Stamp the request's explicit-cache reference (`config.cachedContent`).
|
|
34
|
+
function setCacheConfigTelemetry(span, config) {
|
|
35
|
+
const cachedContent = config && config.cachedContent;
|
|
36
|
+
if (cachedContent) span.setAttribute(CACHED_CONTENT_ID_ATTR, String(cachedContent));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { setCacheTelemetry, setCacheConfigTelemetry };
|
|
@@ -17,9 +17,10 @@ const { trace } = require('@opentelemetry/api');
|
|
|
17
17
|
|
|
18
18
|
const { getProviderName } = require('./get_provider_name');
|
|
19
19
|
const { setAudioTelemetry } = require('./get_audio_telemetry');
|
|
20
|
-
const { setCacheTelemetry } = require('./get_cache_telemetry');
|
|
20
|
+
const { setCacheTelemetry, setCacheConfigTelemetry } = require('./get_cache_telemetry');
|
|
21
21
|
const { setReasoningTelemetry } = require('./get_reasoning_telemetry');
|
|
22
22
|
const { instrumentEmbeddings } = require('./get_embeddings_telemetry');
|
|
23
|
+
const { instrumentCacheOperations } = require('./get_cache_operations_telemetry');
|
|
23
24
|
|
|
24
25
|
class GoogleTelemetryInstrumentor {
|
|
25
26
|
constructor() {
|
|
@@ -29,8 +30,9 @@ class GoogleTelemetryInstrumentor {
|
|
|
29
30
|
|
|
30
31
|
instrument() {
|
|
31
32
|
let Models;
|
|
33
|
+
let Caches;
|
|
32
34
|
try {
|
|
33
|
-
({ Models } = require('@google/genai'));
|
|
35
|
+
({ Models, Caches } = require('@google/genai'));
|
|
34
36
|
} catch (err) {
|
|
35
37
|
throw new Error(
|
|
36
38
|
'weflayr.autoInstrument(GOOGLE_GENAI): the @google/genai client is not installed. ' +
|
|
@@ -45,6 +47,9 @@ class GoogleTelemetryInstrumentor {
|
|
|
45
47
|
this._wrapChat(Models.prototype, 'generateContentStreamInternal', { stream: true });
|
|
46
48
|
// Embeddings: an endpoint the traceloop instrumentor doesn't cover at all.
|
|
47
49
|
instrumentEmbeddings(Models, this._patched);
|
|
50
|
+
// Explicit-cache lifecycle (caches.create/update/delete): endpoints the
|
|
51
|
+
// traceloop instrumentor doesn't cover at all.
|
|
52
|
+
if (Caches) instrumentCacheOperations(Caches, this._patched);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
_wrapChat(owner, methodName, { stream }) {
|
|
@@ -56,6 +61,7 @@ class GoogleTelemetryInstrumentor {
|
|
|
56
61
|
return original.apply(this, args);
|
|
57
62
|
}
|
|
58
63
|
span.setAttribute('gen_ai.system', getProviderName(this));
|
|
64
|
+
setCacheConfigTelemetry(span, args[0] && args[0].config);
|
|
59
65
|
if (!stream) {
|
|
60
66
|
return Promise.resolve(original.apply(this, args)).then((response) => {
|
|
61
67
|
setAudioTelemetry(span, response);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request-side prompt-caching config capture for OpenAI chat spans.
|
|
5
|
+
*
|
|
6
|
+
* OpenAI callers influence the (automatic, server-side) prompt cache with
|
|
7
|
+
* request parameters the traceloop instrumentor never records:
|
|
8
|
+
* `prompt_cache_key` (a client-chosen routing key — requests sharing a key are
|
|
9
|
+
* routed to the same cache shard) and the retention tuning —
|
|
10
|
+
* `prompt_cache_retention` ("in_memory" / "24h") on older models, replaced by
|
|
11
|
+
* `prompt_cache_options: { ttl }` ("30m") on GPT-5.6+.
|
|
12
|
+
*
|
|
13
|
+
* Wrap `startSpan({ type, params })` (covering the streamed and non-streamed
|
|
14
|
+
* paths) to stamp the values as `weflayr.cache.key` / `weflayr.cache.retention`
|
|
15
|
+
* (raw retention/ttl string; the normalizer owns the vocabulary).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CACHE_KEY_ATTR = 'weflayr.cache.key';
|
|
19
|
+
const CACHE_RETENTION_ATTR = 'weflayr.cache.retention';
|
|
20
|
+
|
|
21
|
+
class OpenAICacheConfigInstrumentor {
|
|
22
|
+
constructor() {
|
|
23
|
+
// [instrumentation, methodName, originalFunction] for uninstrument().
|
|
24
|
+
this._patched = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// No-op (and never throws) if the instrumentor's internals have changed shape —
|
|
28
|
+
// the worst case is missing cache-config capture, never a crash.
|
|
29
|
+
instrument(instrumentation) {
|
|
30
|
+
if (!instrumentation) return;
|
|
31
|
+
const startSpanOriginal = instrumentation.startSpan;
|
|
32
|
+
if (typeof startSpanOriginal !== 'function') return;
|
|
33
|
+
|
|
34
|
+
instrumentation.startSpan = function startSpanWithCacheConfig(request) {
|
|
35
|
+
const span = startSpanOriginal.call(this, request);
|
|
36
|
+
try {
|
|
37
|
+
const params = (request && request.params) || {};
|
|
38
|
+
if (params.prompt_cache_key) {
|
|
39
|
+
span.setAttribute(CACHE_KEY_ATTR, String(params.prompt_cache_key));
|
|
40
|
+
}
|
|
41
|
+
const retention =
|
|
42
|
+
params.prompt_cache_retention ||
|
|
43
|
+
(params.prompt_cache_options && params.prompt_cache_options.ttl);
|
|
44
|
+
if (retention) {
|
|
45
|
+
span.setAttribute(CACHE_RETENTION_ATTR, String(retention));
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Never break span creation over an enrichment failure.
|
|
49
|
+
}
|
|
50
|
+
return span;
|
|
51
|
+
};
|
|
52
|
+
this._patched.push([instrumentation, 'startSpan', startSpanOriginal]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
uninstrument() {
|
|
56
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
57
|
+
owner[methodName] = original;
|
|
58
|
+
}
|
|
59
|
+
this._patched = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { OpenAICacheConfigInstrumentor };
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { OpenAIAudioInstrumentor } = require('./get_audio_telemetry');
|
|
14
|
+
const { OpenAICacheConfigInstrumentor } = require('./get_cache_config_telemetry');
|
|
14
15
|
const { OpenAIEmbeddingsInstrumentor } = require('./get_embeddings_telemetry');
|
|
15
16
|
const { OpenAIStreamingUsageInstrumentor } = require('./get_streaming_usage');
|
|
16
17
|
const { OpenAIPromptCachingInstrumentor } = require('./get_cache_and_reasoning_telemetry');
|
|
@@ -22,6 +23,7 @@ class OpenAITelemetryInstrumentor {
|
|
|
22
23
|
new OpenAIEmbeddingsInstrumentor(),
|
|
23
24
|
new OpenAIStreamingUsageInstrumentor(),
|
|
24
25
|
new OpenAIPromptCachingInstrumentor(),
|
|
26
|
+
new OpenAICacheConfigInstrumentor(),
|
|
25
27
|
];
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* is registering it globally so every generateText/streamText/embed call emits
|
|
8
8
|
* GenAI semconv spans through the tracer provider weflayr's setup() installed.
|
|
9
9
|
* `usage: true` adds the `ai.usage.*` token details (reasoning tokens, uncached
|
|
10
|
-
* input) that the semconv attributes do not carry
|
|
10
|
+
* input) that the semconv attributes do not carry; `providerMetadata: true`
|
|
11
|
+
* opts into `ai.response.providerMetadata` on the spans that carry it.
|
|
11
12
|
*
|
|
12
13
|
* The aggregate spans the integration emits around each model call
|
|
13
14
|
* (invoke_agent, agent_step, duplicated embeddings spans) are filtered out
|
|
@@ -37,7 +38,14 @@ class VercelAiSdkInstrumentor {
|
|
|
37
38
|
'experimental_telemetry: { isEnabled: true } is passed on each generateText/streamText call.'
|
|
38
39
|
);
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
const dispatcher = new OpenTelemetry({ usage: true, providerMetadata: true });
|
|
42
|
+
// The integration never emits the raw provider usage, the only place the
|
|
43
|
+
// written cache tokens per TTL (Anthropic's `cache_creation`) are
|
|
44
|
+
// reported; this complement stamps it on the model-call span (see
|
|
45
|
+
// get_cache_creation_telemetry).
|
|
46
|
+
const { VercelCacheCreationInstrumentor } = require('./get_cache_creation_telemetry');
|
|
47
|
+
new VercelCacheCreationInstrumentor().instrument(dispatcher);
|
|
48
|
+
registerTelemetry(dispatcher);
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache-write capture for Vercel AI SDK (ai >= 7) model-call spans.
|
|
5
|
+
*
|
|
6
|
+
* Anthropic reports the written cache tokens per TTL in its raw usage
|
|
7
|
+
* (`usage.cache_creation`, ephemeral_5m/1h buckets — billed at different
|
|
8
|
+
* rates). ai >= 7 carries that verbatim on the model-call telemetry event as
|
|
9
|
+
* `event.usage.raw` (a documented field of LanguageModelUsage), but the
|
|
10
|
+
* `@ai-sdk/otel` integration never emits it, so the span loses it.
|
|
11
|
+
*
|
|
12
|
+
* Wrap the telemetry dispatcher's `onLanguageModelCallEnd(event)` (a public
|
|
13
|
+
* callback of the telemetry interface) to read `usage.cache_creation` off the
|
|
14
|
+
* raw usage and stamp it verbatim as `weflayr.cache.creation` on the
|
|
15
|
+
* still-open model-call span (reached through the integration's call state)
|
|
16
|
+
* before the original handler ends it; the normalizer owns the mapping to TTL
|
|
17
|
+
* tiers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CACHE_CREATION_ATTR = 'weflayr.cache.creation';
|
|
21
|
+
|
|
22
|
+
// The raw `usage.cache_creation` of a provider usage, or undefined when the
|
|
23
|
+
// provider reports none (or reports only zeros — non-caching calls carry no
|
|
24
|
+
// attribute).
|
|
25
|
+
function cacheCreationFromRawUsage(rawUsage) {
|
|
26
|
+
const cacheCreation = rawUsage && rawUsage.cache_creation;
|
|
27
|
+
if (!cacheCreation) return undefined;
|
|
28
|
+
return Object.values(cacheCreation).some(Boolean) ? cacheCreation : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class VercelCacheCreationInstrumentor {
|
|
32
|
+
constructor() {
|
|
33
|
+
// [owner, methodName, originalFunction] for uninstrument().
|
|
34
|
+
this._patched = [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// No-op (and never throws) if the interface is missing — the worst case is
|
|
38
|
+
// the cache-write capture staying empty, exactly the pre-complement
|
|
39
|
+
// behaviour.
|
|
40
|
+
instrument(dispatcher) {
|
|
41
|
+
if (!dispatcher || typeof dispatcher.onLanguageModelCallEnd !== 'function') return;
|
|
42
|
+
const original = dispatcher.onLanguageModelCallEnd;
|
|
43
|
+
|
|
44
|
+
dispatcher.onLanguageModelCallEnd = function onLanguageModelCallEndWithCacheCreation(event) {
|
|
45
|
+
try {
|
|
46
|
+
const cacheCreation = cacheCreationFromRawUsage(event && event.usage && event.usage.raw);
|
|
47
|
+
if (cacheCreation) {
|
|
48
|
+
const callState = this.getCallState && this.getCallState(event.callId);
|
|
49
|
+
const span = callState && callState.inferenceSpan;
|
|
50
|
+
if (span && typeof span.setAttribute === 'function') {
|
|
51
|
+
span.setAttribute(CACHE_CREATION_ATTR, JSON.stringify(cacheCreation));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Never break span finalization over an enrichment failure.
|
|
56
|
+
}
|
|
57
|
+
return original.call(this, event);
|
|
58
|
+
};
|
|
59
|
+
this._patched.push([dispatcher, 'onLanguageModelCallEnd', original]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
uninstrument() {
|
|
63
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
64
|
+
owner[methodName] = original;
|
|
65
|
+
}
|
|
66
|
+
this._patched = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { VercelCacheCreationInstrumentor, cacheCreationFromRawUsage };
|
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, customerName,
|
|
9
|
+
// (featureName, customerName, providerNameOverride, 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
|
|
@@ -36,7 +36,13 @@ function getMergedMetadataFromContext(parentContext) {
|
|
|
36
36
|
return existing && typeof existing === 'object' ? { ...existing } : {};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function _buildAttributes({
|
|
39
|
+
function _buildAttributes({
|
|
40
|
+
featureName,
|
|
41
|
+
customerName,
|
|
42
|
+
providerNameOverride,
|
|
43
|
+
extraTags,
|
|
44
|
+
spanUuid,
|
|
45
|
+
}) {
|
|
40
46
|
const attributes = {
|
|
41
47
|
[FEATURE_NAME_ATTR]: featureName,
|
|
42
48
|
[CUSTOMER_NAME_ATTR]: customerName,
|
|
@@ -45,8 +51,8 @@ function _buildAttributes({ featureName, customerName, providerName, extraTags,
|
|
|
45
51
|
for (const [key, value] of Object.entries(extraTags)) {
|
|
46
52
|
attributes[`${EXTRA_TAG_PREFIX}${key}`] = value;
|
|
47
53
|
}
|
|
48
|
-
if (
|
|
49
|
-
attributes[PROVIDER_NAME_ATTR] =
|
|
54
|
+
if (providerNameOverride !== undefined && providerNameOverride !== null) {
|
|
55
|
+
attributes[PROVIDER_NAME_ATTR] = providerNameOverride;
|
|
50
56
|
}
|
|
51
57
|
return attributes;
|
|
52
58
|
}
|
|
@@ -62,8 +68,8 @@ function _buildAttributes({ featureName, customerName, providerName, extraTags,
|
|
|
62
68
|
* propagateMetadata call" contract.
|
|
63
69
|
*/
|
|
64
70
|
function propagateMetadata(options, fn) {
|
|
65
|
-
const { featureName, customerId } = options ?? {};
|
|
66
|
-
let { customerName } = options ?? {};
|
|
71
|
+
const { featureName, customerId, providerName } = options ?? {};
|
|
72
|
+
let { customerName, providerNameOverride } = options ?? {};
|
|
67
73
|
if (customerId != null) {
|
|
68
74
|
process.emitWarning(
|
|
69
75
|
'propagateMetadata: `customerId` is deprecated, use `customerName`',
|
|
@@ -71,10 +77,17 @@ function propagateMetadata(options, fn) {
|
|
|
71
77
|
);
|
|
72
78
|
customerName = customerName ?? customerId;
|
|
73
79
|
}
|
|
80
|
+
if (providerName != null) {
|
|
81
|
+
process.emitWarning(
|
|
82
|
+
'propagateMetadata: `providerName` is deprecated, use `providerNameOverride`',
|
|
83
|
+
'DeprecationWarning'
|
|
84
|
+
);
|
|
85
|
+
providerNameOverride = providerNameOverride ?? providerName;
|
|
86
|
+
}
|
|
74
87
|
if (featureName == null || customerName == null) {
|
|
75
88
|
throw new Error('propagateMetadata: `featureName` and `customerName` are required');
|
|
76
89
|
}
|
|
77
|
-
const normalizedOptions = { ...options, customerName };
|
|
90
|
+
const normalizedOptions = { ...options, customerName, providerNameOverride };
|
|
78
91
|
|
|
79
92
|
if (typeof fn === 'function') {
|
|
80
93
|
return _runWithMetadata(normalizedOptions, fn);
|
|
@@ -98,11 +111,11 @@ function propagateMetadata(options, fn) {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
function _runWithMetadata(options, fn) {
|
|
101
|
-
const { featureName, customerName,
|
|
114
|
+
const { featureName, customerName, providerNameOverride, extraTags } = options;
|
|
102
115
|
const attributes = _buildAttributes({
|
|
103
116
|
featureName,
|
|
104
117
|
customerName,
|
|
105
|
-
|
|
118
|
+
providerNameOverride,
|
|
106
119
|
extraTags: extraTags ?? {},
|
|
107
120
|
spanUuid: randomUUID().replace(/-/g, ''),
|
|
108
121
|
});
|
package/src/otel/span-filter.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// - Translated from TypeScript to JavaScript
|
|
8
8
|
// - Dropped the LANGFUSE_TRACER_NAME special case (we have no SDK-internal tracer)
|
|
9
9
|
// - Dropped isLangfuseSpan
|
|
10
|
+
// - Export only spans from known LLM instrumentation scopes: general-purpose
|
|
11
|
+
// APM SDKs (Sentry, Datadog, ...) also instrument LLM calls with gen_ai.*
|
|
12
|
+
// attributes, so the original "any span with gen_ai.* attributes" check
|
|
13
|
+
// exported the same call twice
|
|
10
14
|
//
|
|
11
15
|
// See LICENSE-MIT in the package root for the full license text.
|
|
12
16
|
|
|
@@ -15,19 +19,24 @@
|
|
|
15
19
|
const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
16
20
|
'agent_framework',
|
|
17
21
|
'ai',
|
|
22
|
+
'gen_ai',
|
|
18
23
|
'haystack',
|
|
19
24
|
'langsmith',
|
|
20
25
|
'litellm',
|
|
26
|
+
'mistralai_sdk_tracer',
|
|
21
27
|
'openinference',
|
|
22
28
|
'opentelemetry.instrumentation.anthropic',
|
|
23
29
|
'opentelemetry.instrumentation.aws_bedrock',
|
|
24
30
|
'opentelemetry.instrumentation.bedrock',
|
|
31
|
+
'opentelemetry.instrumentation.cohere',
|
|
25
32
|
'opentelemetry.instrumentation.gemini',
|
|
26
33
|
'opentelemetry.instrumentation.google_genai',
|
|
27
34
|
'opentelemetry.instrumentation.google_generativeai',
|
|
28
35
|
'opentelemetry.instrumentation.openai',
|
|
29
36
|
'opentelemetry.instrumentation.openai_v2',
|
|
30
|
-
'
|
|
37
|
+
'opentelemetry.instrumentation.vertex_ai',
|
|
38
|
+
'opentelemetry.instrumentation.vertexai',
|
|
39
|
+
'weflayr.instrumentation',
|
|
31
40
|
'strands-agents',
|
|
32
41
|
'vllm',
|
|
33
42
|
'@traceloop/instrumentation-openai',
|
|
@@ -43,27 +52,18 @@ const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
|
43
52
|
'@traceloop/instrumentation-mcp',
|
|
44
53
|
];
|
|
45
54
|
|
|
46
|
-
const EXACT_LLM_INSTRUMENTATION_SCOPES = new Set(['ai']);
|
|
47
|
-
|
|
48
|
-
function isGenAISpan(span) {
|
|
49
|
-
return Object.keys(span.attributes).some((key) => key.startsWith('gen_ai.'));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
55
|
function isKnownLLMInstrumentor(span) {
|
|
53
56
|
const scope = span.instrumentationScope.name;
|
|
54
57
|
return KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.some(
|
|
55
|
-
(prefix) =>
|
|
56
|
-
scope === prefix ||
|
|
57
|
-
(!EXACT_LLM_INSTRUMENTATION_SCOPES.has(prefix) && scope.startsWith(`${prefix}.`))
|
|
58
|
+
(prefix) => scope === prefix || scope.startsWith(`${prefix}.`)
|
|
58
59
|
);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
function isDefaultExportSpan(span) {
|
|
62
|
-
return
|
|
63
|
+
return isKnownLLMInstrumentor(span);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
module.exports = {
|
|
66
|
-
isGenAISpan,
|
|
67
67
|
isKnownLLMInstrumentor,
|
|
68
68
|
isDefaultExportSpan,
|
|
69
69
|
};
|
|
@@ -49,6 +49,11 @@ function _isContentAttribute(key) {
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Stamped on every exported span when a `mask` is configured: the mask
|
|
53
|
+
// function's source code, so the stored raw event shows what redaction ran
|
|
54
|
+
// client-side when debugging missing attributes.
|
|
55
|
+
const MASK_FUNCTION_ATTRIBUTE = 'weflayr.mask_function';
|
|
56
|
+
|
|
52
57
|
class WeflayrSpanProcessor {
|
|
53
58
|
constructor({ exporter, exportMode = 'batch', defaultTags = {}, mask, shouldExportSpan } = {}) {
|
|
54
59
|
if (!exporter) {
|
|
@@ -60,6 +65,7 @@ class WeflayrSpanProcessor {
|
|
|
60
65
|
: new BatchSpanProcessor(exporter);
|
|
61
66
|
this._defaultTags = defaultTags;
|
|
62
67
|
this._mask = mask;
|
|
68
|
+
this._maskFunctionSource = mask ? String(mask) : null;
|
|
63
69
|
this._shouldExportSpan = shouldExportSpan ?? isDefaultExportSpan;
|
|
64
70
|
this._vercelAiSdkSpanFilter = new VercelAiSdkSpanFilter();
|
|
65
71
|
// LLM spans we've started but not yet seen end. On forceFlush we end any
|
|
@@ -126,6 +132,8 @@ class WeflayrSpanProcessor {
|
|
|
126
132
|
} catch {
|
|
127
133
|
for (const k of Object.keys(span.attributes)) delete span.attributes[k];
|
|
128
134
|
}
|
|
135
|
+
// Stamped after masking so the mask can't strip it.
|
|
136
|
+
span.attributes[MASK_FUNCTION_ATTRIBUTE] = this._maskFunctionSource;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
return this._delegate.onEnd(span);
|
|
@@ -65,7 +65,7 @@ class VercelAiSdkSpanFilter {
|
|
|
65
65
|
// `@ai-sdk/otel` reports the Vercel AI Gateway as the bare provider id
|
|
66
66
|
// "gateway" — too generic a name to special-case downstream. Stamp the weflayr
|
|
67
67
|
// provider override (which the normalizer prefers) with the canonical name.
|
|
68
|
-
// A
|
|
68
|
+
// A providerNameOverride set through propagateMetadata was stamped at span start and
|
|
69
69
|
// wins.
|
|
70
70
|
function stampVercelGatewayProviderName(span) {
|
|
71
71
|
if (
|