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.
Files changed (101) hide show
  1. package/.prettierignore +2 -0
  2. package/dist/api/caseConversion.d.ts +16 -0
  3. package/dist/api/caseConversion.js +45 -0
  4. package/dist/api/generated/client/client.gen.d.ts +2 -0
  5. package/dist/api/generated/client/client.gen.js +220 -0
  6. package/dist/api/generated/client/index.d.ts +10 -0
  7. package/dist/api/generated/client/index.js +17 -0
  8. package/dist/api/generated/client/types.gen.d.ts +120 -0
  9. package/dist/api/generated/client/types.gen.js +3 -0
  10. package/dist/api/generated/client/utils.gen.d.ts +37 -0
  11. package/dist/api/generated/client/utils.gen.js +239 -0
  12. package/dist/api/generated/client.gen.d.ts +12 -0
  13. package/dist/api/generated/client.gen.js +6 -0
  14. package/dist/api/generated/core/auth.gen.d.ts +25 -0
  15. package/dist/api/generated/core/auth.gen.js +18 -0
  16. package/dist/api/generated/core/bodySerializer.gen.d.ts +25 -0
  17. package/dist/api/generated/core/bodySerializer.gen.js +60 -0
  18. package/dist/api/generated/core/params.gen.d.ts +43 -0
  19. package/dist/api/generated/core/params.gen.js +112 -0
  20. package/dist/api/generated/core/pathSerializer.gen.d.ts +33 -0
  21. package/dist/api/generated/core/pathSerializer.gen.js +115 -0
  22. package/dist/api/generated/core/queryKeySerializer.gen.d.ts +18 -0
  23. package/dist/api/generated/core/queryKeySerializer.gen.js +98 -0
  24. package/dist/api/generated/core/serverSentEvents.gen.d.ts +71 -0
  25. package/dist/api/generated/core/serverSentEvents.gen.js +135 -0
  26. package/dist/api/generated/core/types.gen.d.ts +83 -0
  27. package/dist/api/generated/core/types.gen.js +3 -0
  28. package/dist/api/generated/core/utils.gen.d.ts +19 -0
  29. package/dist/api/generated/core/utils.gen.js +93 -0
  30. package/dist/api/generated/facade.gen.d.ts +208 -0
  31. package/dist/api/generated/facade.gen.js +52 -0
  32. package/dist/api/generated/index.d.ts +2 -0
  33. package/dist/api/generated/index.js +17 -0
  34. package/dist/api/generated/sdk.gen.d.ts +63 -0
  35. package/dist/api/generated/sdk.gen.js +125 -0
  36. package/dist/api/generated/types.gen.d.ts +711 -0
  37. package/dist/api/generated/types.gen.js +3 -0
  38. package/dist/api/index.d.ts +27 -0
  39. package/dist/api/index.js +48 -0
  40. package/dist/api/makeEndpoint.d.ts +27 -0
  41. package/dist/api/makeEndpoint.js +37 -0
  42. package/index.d.ts +25 -16
  43. package/openapi-ts.config.mjs +9 -0
  44. package/package.json +9 -2
  45. package/scripts/generate_facade.mjs +86 -0
  46. package/src/api/caseConversion.ts +58 -0
  47. package/src/api/generated/client/client.gen.ts +277 -0
  48. package/src/api/generated/client/index.ts +27 -0
  49. package/src/api/generated/client/types.gen.ts +218 -0
  50. package/src/api/generated/client/utils.gen.ts +316 -0
  51. package/src/api/generated/client.gen.ts +16 -0
  52. package/src/api/generated/core/auth.gen.ts +48 -0
  53. package/src/api/generated/core/bodySerializer.gen.ts +82 -0
  54. package/src/api/generated/core/params.gen.ts +178 -0
  55. package/src/api/generated/core/pathSerializer.gen.ts +171 -0
  56. package/src/api/generated/core/queryKeySerializer.gen.ts +117 -0
  57. package/src/api/generated/core/serverSentEvents.gen.ts +242 -0
  58. package/src/api/generated/core/types.gen.ts +110 -0
  59. package/src/api/generated/core/utils.gen.ts +140 -0
  60. package/src/api/generated/facade.gen.ts +80 -0
  61. package/src/api/generated/index.ts +4 -0
  62. package/src/api/generated/sdk.gen.ts +139 -0
  63. package/src/api/generated/types.gen.ts +792 -0
  64. package/src/api/index.ts +45 -0
  65. package/src/api/makeEndpoint.ts +54 -0
  66. package/src/auto-instrument.js +19 -6
  67. package/src/index.js +5 -0
  68. package/src/instrumentation/anthropic/get_streaming_stop_reason_telemetry.js +121 -0
  69. package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
  70. package/src/instrumentation/anthropic/set_telemetry_attributes.js +13 -3
  71. package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
  72. package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
  73. package/src/instrumentation/bedrock/set_telemetry_attributes.js +12 -3
  74. package/src/instrumentation/cohere/get_embeddings_telemetry.js +10 -0
  75. package/src/instrumentation/google/get_audio_telemetry.js +1 -2
  76. package/src/instrumentation/google/get_cache_telemetry.js +1 -3
  77. package/src/instrumentation/google/get_embeddings_telemetry.js +22 -1
  78. package/src/instrumentation/google/get_provider_name.js +2 -1
  79. package/src/instrumentation/openai/get_embeddings_telemetry.js +12 -0
  80. package/src/instrumentation/vercel/full_instrumentation.js +44 -0
  81. package/src/otel/exporter.js +1 -6
  82. package/src/otel/propagation.js +21 -12
  83. package/src/otel/span-processor.js +41 -2
  84. package/src/otel/vercel-ai-sdk-span-filter.js +79 -0
  85. package/src/setup.js +1 -2
  86. package/tests/api/index.test.js +330 -0
  87. package/tests/index.test.js +150 -15
  88. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +33 -0
  89. package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
  90. package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
  91. package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
  92. package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
  93. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +3 -0
  94. package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +18 -4
  95. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +3 -0
  96. package/tests/instrumentation/google/set_telemetry_attributes.test.js +33 -1
  97. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +19 -0
  98. package/tests/instrumentation/openai/get_streaming_usage.test.js +29 -0
  99. package/tests/instrumentation/vercel/full_instrumentation.test.js +16 -0
  100. package/tests/otel/vercel-ai-sdk-span-filter.test.js +159 -0
  101. 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 };
@@ -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
- // 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;
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.
@@ -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, customerId, providerName, extraTags) plus an
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 CUSTOMER_ID_ATTR = 'weflayr.customer_id';
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, customerId, providerName, extraTags, spanUuid }) {
39
+ function _buildAttributes({ featureName, customerName, providerName, extraTags, spanUuid }) {
40
40
  const attributes = {
41
41
  [FEATURE_NAME_ATTR]: featureName,
42
- [CUSTOMER_ID_ATTR]: customerId,
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
- if (featureName == null || customerId == null) {
67
- throw new Error('propagateMetadata: `featureName` and `customerId` are required');
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(options, fn);
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(options, () => original.apply(this, args));
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(options, () => target.apply(this, args));
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, customerId, providerName, extraTags } = options;
101
+ const { featureName, customerName, providerName, extraTags } = options;
93
102
  const attributes = _buildAttributes({
94
103
  featureName,
95
- customerId,
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
- CUSTOMER_ID_ATTR,
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
- if (!this._shouldExportSpan(span)) {
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
+ });