dd-trace 6.18.0 → 6.20.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.
Files changed (123) hide show
  1. package/LICENSE-3rdparty.csv +1 -0
  2. package/ci/vitest-no-worker-init-setup.mjs +22 -12
  3. package/index.d.ts +166 -24
  4. package/package.json +7 -7
  5. package/packages/datadog-esbuild/index.js +9 -6
  6. package/packages/datadog-instrumentations/src/ai.js +26 -0
  7. package/packages/datadog-instrumentations/src/anthropic.js +111 -9
  8. package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
  9. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
  10. package/packages/datadog-instrumentations/src/cucumber.js +40 -4
  11. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -7
  12. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +2 -2
  13. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +25 -0
  14. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
  15. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
  16. package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
  17. package/packages/datadog-instrumentations/src/jest.js +3 -10
  18. package/packages/datadog-instrumentations/src/playwright.js +26 -0
  19. package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
  20. package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
  21. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
  22. package/packages/datadog-plugin-cucumber/src/index.js +19 -4
  23. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
  24. package/packages/datadog-plugin-fetch/src/index.js +3 -0
  25. package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
  26. package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
  27. package/packages/datadog-plugin-undici/src/index.js +5 -0
  28. package/packages/dd-trace/src/aiguard/client.js +38 -19
  29. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
  30. package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
  31. package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
  32. package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
  33. package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
  34. package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -4
  35. package/packages/dd-trace/src/config/remote_config.js +12 -0
  36. package/packages/dd-trace/src/config/supported-configurations.json +26 -5
  37. package/packages/dd-trace/src/constants.js +2 -0
  38. package/packages/dd-trace/src/debugger/constants.js +13 -4
  39. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +14 -1
  40. package/packages/dd-trace/src/debugger/devtools_client/condition.js +109 -6
  41. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -0
  42. package/packages/dd-trace/src/debugger/devtools_client/index.js +65 -6
  43. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +22 -10
  44. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +12 -8
  45. package/packages/dd-trace/src/debugger/devtools_client/send.js +14 -0
  46. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +43 -5
  47. package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +3 -8
  48. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +2 -122
  49. package/packages/dd-trace/src/debugger/guardrail-metrics.js +0 -2
  50. package/packages/dd-trace/src/debugger/index.js +60 -28
  51. package/packages/dd-trace/src/debugger/inspect-segment.js +92 -23
  52. package/packages/dd-trace/src/debugger/pause-duration-histogram.js +74 -0
  53. package/packages/dd-trace/src/debugger/probe_sampler.js +192 -52
  54. package/packages/dd-trace/src/debugger/redaction.js +139 -0
  55. package/packages/dd-trace/src/knuth-hash.js +17 -0
  56. package/packages/dd-trace/src/llmobs/constants/tags.js +33 -8
  57. package/packages/dd-trace/src/llmobs/experiments/client.js +66 -2
  58. package/packages/dd-trace/src/llmobs/experiments/evaluator.js +138 -0
  59. package/packages/dd-trace/src/llmobs/experiments/experiment.js +213 -90
  60. package/packages/dd-trace/src/llmobs/experiments/index.js +40 -1
  61. package/packages/dd-trace/src/llmobs/experiments/noop.js +53 -2
  62. package/packages/dd-trace/src/llmobs/experiments/remote-evaluator.js +110 -0
  63. package/packages/dd-trace/src/llmobs/experiments/util.js +34 -6
  64. package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
  65. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
  66. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
  67. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
  68. package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
  69. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
  70. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
  71. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
  72. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
  73. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
  74. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
  75. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
  76. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +15 -2
  77. package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
  78. package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
  79. package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
  80. package/packages/dd-trace/src/llmobs/sdk.js +19 -3
  81. package/packages/dd-trace/src/llmobs/span_processor.js +52 -91
  82. package/packages/dd-trace/src/llmobs/tagger.js +37 -36
  83. package/packages/dd-trace/src/llmobs/writers/base.js +1 -6
  84. package/packages/dd-trace/src/openfeature/constants/constants.js +10 -0
  85. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -1
  86. package/packages/dd-trace/src/openfeature/writers/base.js +39 -18
  87. package/packages/dd-trace/src/openfeature/writers/flag-eval-evp-hook.js +105 -0
  88. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-aggregation.js +190 -0
  89. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-consumer.js +233 -0
  90. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-context.js +262 -0
  91. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-payload.js +181 -0
  92. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-pii.js +108 -0
  93. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-telemetry.js +154 -0
  94. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-worker.js +77 -0
  95. package/packages/dd-trace/src/openfeature/writers/flag-evaluations.js +398 -0
  96. package/packages/dd-trace/src/openfeature/writers/util.js +5 -4
  97. package/packages/dd-trace/src/opentelemetry/context_manager.js +3 -0
  98. package/packages/dd-trace/src/opentelemetry/span_context.js +6 -2
  99. package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
  100. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -0
  101. package/packages/dd-trace/src/opentelemetry/tracer.js +16 -18
  102. package/packages/dd-trace/src/opentracing/propagation/text_map.js +116 -25
  103. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +80 -16
  104. package/packages/dd-trace/src/opentracing/span.js +14 -9
  105. package/packages/dd-trace/src/opentracing/tracer.js +10 -3
  106. package/packages/dd-trace/src/otel-sampling.js +171 -0
  107. package/packages/dd-trace/src/plugins/index.js +0 -1
  108. package/packages/dd-trace/src/plugins/util/test.js +4 -0
  109. package/packages/dd-trace/src/priority_sampler.js +155 -33
  110. package/packages/dd-trace/src/profiler.js +51 -5
  111. package/packages/dd-trace/src/profiling/profiler.js +24 -20
  112. package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
  113. package/packages/dd-trace/src/ritm.js +12 -9
  114. package/packages/dd-trace/src/sampler.js +2 -5
  115. package/packages/dd-trace/src/sampling_rule.js +4 -8
  116. package/packages/dd-trace/src/span_format.js +6 -0
  117. package/packages/dd-trace/src/span_processor.js +32 -3
  118. package/packages/dd-trace/src/standalone/index.js +5 -4
  119. package/packages/dd-trace/src/standalone/tracesource_priority_sampler.js +25 -5
  120. package/packages/dd-trace/src/telemetry/metrics.js +4 -3
  121. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  122. package/packages/datadog-instrumentations/src/postgres.js +0 -7
  123. package/packages/datadog-instrumentations/src/supabase.js +0 -15
@@ -0,0 +1,190 @@
1
+ 'use strict'
2
+
3
+ const { createHash } = require('node:crypto')
4
+
5
+ const {
6
+ FLAG_EVALUATION_DEGRADED_CAP,
7
+ FLAG_EVALUATION_GLOBAL_CAP,
8
+ FLAG_EVALUATION_PER_FLAG_CAP,
9
+ } = require('../constants/constants')
10
+ const { validatedContextEntries, snapshotFromEntries } = require('./flag-evaluation-context')
11
+ const { normalizeTargetingKey, optionalKey, protectedErrorCode } =
12
+ require('./flag-evaluation-pii')
13
+ const { recordDegraded, recordDropped } = require('./flag-evaluation-telemetry')
14
+
15
+ // V8 hashes longer strings by length alone, making equal-length identities expensive to distinguish.
16
+ const MAX_DIRECT_IDENTITY_LENGTH = 16_383
17
+
18
+ /** @typedef {import('./flag-evaluation-context').ContextSnapshot} ContextSnapshot */
19
+ /**
20
+ * @typedef {object} FlagEvaluationEvent
21
+ * @property {string} flagKey
22
+ * @property {string} [variant]
23
+ * @property {string} [allocationKey]
24
+ * @property {boolean} runtimeDefault
25
+ * @property {unknown} [errorCode]
26
+ * @property {unknown} [targetingKey]
27
+ * @property {ContextSnapshot} [attrs]
28
+ * @property {unknown} [observeFullEvaluationData]
29
+ * @property {number} timestamp
30
+ */
31
+ /**
32
+ * @typedef {object} AggregationDimensions
33
+ * @property {string} flagKey
34
+ * @property {string} [variant]
35
+ * @property {string} [allocation]
36
+ * @property {string} [error]
37
+ */
38
+ /**
39
+ * @typedef {AggregationDimensions & {
40
+ * runtimeDefault: boolean,
41
+ * rawTargetingKey?: string,
42
+ * attrs?: ContextSnapshot,
43
+ * consent: boolean,
44
+ * count: number,
45
+ * first: number,
46
+ * last: number
47
+ * }} AggregationEntry
48
+ */
49
+ /**
50
+ * @typedef {object} AggregationResult
51
+ * @property {Map<string, AggregationEntry>} full
52
+ * @property {Map<string, AggregationEntry>} degraded
53
+ */
54
+
55
+ /**
56
+ * Merge one observation into an aggregation entry.
57
+ *
58
+ * @param {AggregationEntry} entry
59
+ * @param {number} timestamp
60
+ * @param {unknown} consent
61
+ */
62
+ function observeEntry (entry, timestamp, consent) {
63
+ entry.count++
64
+ entry.first = Math.min(entry.first, timestamp)
65
+ entry.last = Math.max(entry.last, timestamp)
66
+ entry.consent &&= consent === true
67
+ }
68
+
69
+ /** @param {Map<string, AggregationEntry>} entries */
70
+ function evaluationCount (entries) {
71
+ let count = 0
72
+ for (const entry of entries.values()) count += entry.count
73
+ return count
74
+ }
75
+
76
+ class FlagEvaluationAggregator {
77
+ /** @type {Map<string, AggregationEntry>} */
78
+ #full = new Map()
79
+ /** @type {Map<string, AggregationEntry>} */
80
+ #degraded = new Map()
81
+ /** @type {Map<string, number>} */
82
+ #perFlag = new Map()
83
+
84
+ /** @param {FlagEvaluationEvent} event */
85
+ add (event) {
86
+ const flagKey = normalizeTargetingKey(event.flagKey)
87
+ if (flagKey === undefined || !Number.isSafeInteger(event.timestamp)) {
88
+ recordDropped('serialization_error')
89
+ return
90
+ }
91
+ const consent = event.observeFullEvaluationData === true
92
+ const targetingKey = normalizeTargetingKey(event.targetingKey)
93
+ const contextEntries = consent ? validatedContextEntries(event.attrs) : undefined
94
+ const error = protectedErrorCode(event.errorCode)
95
+ const variant = optionalKey(event.variant)
96
+ const allocation = optionalKey(event.allocationKey)
97
+ // Group by the raw key; the serializer independently hashes protected rows once per output bucket.
98
+ const identity = JSON.stringify([
99
+ flagKey, variant, allocation, event.runtimeDefault === true, error,
100
+ targetingKey, contextEntries, consent,
101
+ ])
102
+ // Keep digest keys separate from short JSON identities, which always start with '['.
103
+ const fullKey = identity.length > MAX_DIRECT_IDENTITY_LENGTH
104
+ ? '#' + createHash('sha256').update(identity).digest('base64')
105
+ : identity
106
+ const existing = this.#full.get(fullKey)
107
+ if (existing) {
108
+ observeEntry(existing, event.timestamp, consent)
109
+ return
110
+ }
111
+
112
+ const perFlag = this.#perFlag.get(flagKey) ?? 0
113
+ if (perFlag >= FLAG_EVALUATION_PER_FLAG_CAP) {
114
+ this.#addDegraded(event, { flagKey, variant, allocation, error })
115
+ return
116
+ }
117
+ if (this.#full.size >= FLAG_EVALUATION_GLOBAL_CAP) {
118
+ this.#addDegraded(event, { flagKey, variant, allocation, error })
119
+ return
120
+ }
121
+
122
+ this.#perFlag.set(flagKey, perFlag + 1)
123
+ this.#full.set(fullKey, {
124
+ flagKey,
125
+ variant,
126
+ allocation,
127
+ runtimeDefault: event.runtimeDefault === true,
128
+ error,
129
+ rawTargetingKey: targetingKey,
130
+ // Repeated observations need validation and identity, but no new retained snapshot.
131
+ attrs: snapshotFromEntries(contextEntries),
132
+ consent,
133
+ count: 1,
134
+ first: event.timestamp,
135
+ last: event.timestamp,
136
+ })
137
+ }
138
+
139
+ /** @returns {AggregationResult} */
140
+ take () {
141
+ const result = { full: this.#full, degraded: this.#degraded }
142
+ this.#full = new Map()
143
+ this.#degraded = new Map()
144
+ this.#perFlag = new Map()
145
+ return result
146
+ }
147
+
148
+ clear () {
149
+ const count = evaluationCount(this.#full) + evaluationCount(this.#degraded)
150
+ this.#full = new Map()
151
+ this.#degraded = new Map()
152
+ this.#perFlag = new Map()
153
+ return count
154
+ }
155
+
156
+ get size () {
157
+ return this.#full.size + this.#degraded.size
158
+ }
159
+
160
+ /**
161
+ * @param {FlagEvaluationEvent} event
162
+ * @param {AggregationDimensions} dimensions
163
+ */
164
+ #addDegraded (event, dimensions) {
165
+ recordDegraded('cardinality_cap')
166
+ const key = JSON.stringify([
167
+ dimensions.flagKey, dimensions.variant, dimensions.allocation,
168
+ event.runtimeDefault === true, dimensions.error,
169
+ ])
170
+ const existing = this.#degraded.get(key)
171
+ if (existing) {
172
+ observeEntry(existing, event.timestamp, event.observeFullEvaluationData)
173
+ return
174
+ }
175
+ if (this.#degraded.size >= FLAG_EVALUATION_DEGRADED_CAP) {
176
+ recordDropped('degraded_cap')
177
+ return
178
+ }
179
+ this.#degraded.set(key, {
180
+ ...dimensions,
181
+ runtimeDefault: event.runtimeDefault === true,
182
+ consent: event.observeFullEvaluationData === true,
183
+ count: 1,
184
+ first: event.timestamp,
185
+ last: event.timestamp,
186
+ })
187
+ }
188
+ }
189
+
190
+ module.exports = { FlagEvaluationAggregator }
@@ -0,0 +1,233 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ EVP_PAYLOAD_SIZE_LIMIT,
5
+ FLAG_EVALUATION_ENDPOINT,
6
+ FLAG_EVALUATION_FLUSH_INTERVAL,
7
+ FLAG_EVALUATION_QUEUE_CAP,
8
+ } = require('../constants/constants')
9
+ const {
10
+ EVP_EVENT_PLATFORM_SUBDOMAIN,
11
+ EVP_PROXY_PATH_V2,
12
+ EVP_SUBDOMAIN_HEADER_NAME,
13
+ } = require('../../evp_proxy/constants')
14
+ const { joinEVPProxyPath } = require('../../evp_proxy/path')
15
+ const { FlagEvaluationAggregator } = require('./flag-evaluation-aggregation')
16
+ const { iterateFlagEvaluationPayloads } = require('./flag-evaluation-payload')
17
+ const { normalizeFlagEvaluationEvent, normalizeTargetingKey } = require('./flag-evaluation-pii')
18
+ const { recordDropped, recordTargetingKeyOmitted } = require('./flag-evaluation-telemetry')
19
+ const BaseFFEWriter = require('./base')
20
+
21
+ /** @typedef {import('./flag-evaluation-aggregation').FlagEvaluationEvent} FlagEvaluationEvent */
22
+
23
+ /**
24
+ * @typedef {object} FlagEvaluationRoute
25
+ * @property {URL} url
26
+ * @property {string} basePath
27
+ * @property {object} [headers]
28
+ * @property {import('node:https').Agent} [agent]
29
+ * @property {() => void} [onFallback]
30
+ * @property {() => void} [onUnavailable]
31
+ * @property {FlagEvaluationRoute} [fallback]
32
+ */
33
+
34
+ class FlagEvaluationConsumer extends BaseFFEWriter {
35
+ #enabled = false
36
+ #closed = false
37
+ #queue = []
38
+ #immediate
39
+ #aggregator = new FlagEvaluationAggregator()
40
+ #context
41
+ #pendingBytes = 0
42
+ #pending
43
+ #pendingCount = 0
44
+ #pumping = false
45
+ #flushRequested = false
46
+ #onProcessed
47
+ #onDelivered
48
+ #onIdle
49
+
50
+ /**
51
+ * @param {import('../../config/config-base')} config
52
+ * @param {FlagEvaluationRoute} [route]
53
+ * @param {object} [callbacks] - Worker ownership callbacks
54
+ * @param {(count: number) => void} [callbacks.onProcessed] - Release input credit after processing
55
+ * @param {(count: number) => void} [callbacks.onDelivered] - Release observations after successful delivery
56
+ * @param {() => void} [callbacks.onIdle] - Called once shutdown has drained delivery
57
+ */
58
+ constructor (config, route, { onProcessed, onDelivered, onIdle } = {}) {
59
+ route ??= {
60
+ url: /** @type {URL} */ (config.url),
61
+ basePath: EVP_PROXY_PATH_V2,
62
+ }
63
+ const headers = route.headers ?? { [EVP_SUBDOMAIN_HEADER_NAME]: EVP_EVENT_PLATFORM_SUBDOMAIN }
64
+ super({
65
+ config,
66
+ interval: FLAG_EVALUATION_FLUSH_INTERVAL,
67
+ agentUrl: route.url,
68
+ endpoint: joinEVPProxyPath(route.basePath, FLAG_EVALUATION_ENDPOINT),
69
+ headers,
70
+ // Consent to send evaluation data never permits logging an echoed response body or error message.
71
+ formatError: (error, statusCode) => statusCode === undefined
72
+ ? `Transport error ${error.code ?? 'UNKNOWN'}`
73
+ : `HTTP status ${statusCode}`,
74
+ })
75
+ this.#setRoute({ ...route, headers })
76
+ this.#onProcessed = onProcessed
77
+ this.#onDelivered = onDelivered
78
+ this.#onIdle = onIdle
79
+
80
+ this.#context = { service: typeof config.service === 'string' ? config.service : '' }
81
+ if (typeof config.env === 'string') this.#context.env = config.env
82
+ if (typeof config.version === 'string') this.#context.version = config.version
83
+ }
84
+
85
+ hasCapacity () {
86
+ return this.#enabled && !this.#closed && this.#queue.length < FLAG_EVALUATION_QUEUE_CAP
87
+ }
88
+
89
+ /**
90
+ * @param {boolean} enabled
91
+ * @param {FlagEvaluationRoute} [route]
92
+ */
93
+ setEnabled (enabled, route) {
94
+ if (route) this.#setRoute(route)
95
+ if (!enabled && this.#enabled) this.#discardBuffered('unavailable')
96
+ this.#enabled = enabled && !this.#closed
97
+ }
98
+
99
+ /** @param {FlagEvaluationEvent} event */
100
+ enqueue (event) {
101
+ if (this.#closed) {
102
+ recordDropped('closed')
103
+ return false
104
+ }
105
+ if (!this.#enabled) {
106
+ recordDropped('unavailable')
107
+ return false
108
+ }
109
+ if (this.#queue.length >= FLAG_EVALUATION_QUEUE_CAP) {
110
+ recordDropped('queue_overflow')
111
+ return false
112
+ }
113
+
114
+ const targetingKey = normalizeTargetingKey(event.targetingKey)
115
+ if (targetingKey === undefined && event.targetingKey !== undefined && event.targetingKey !== null) {
116
+ recordTargetingKeyOmitted()
117
+ }
118
+ this.#queue.push(normalizeFlagEvaluationEvent(event, targetingKey))
119
+ if (this.#immediate === undefined) {
120
+ this.#immediate = setImmediate(() => this.#drain())
121
+ this.#immediate.unref?.()
122
+ }
123
+ return true
124
+ }
125
+
126
+ flush () {
127
+ this.#cancelImmediate()
128
+ this.#drain()
129
+ if (!this.#enabled && !this.#closed) return
130
+ this.#flushRequested = true
131
+ this.#pump()
132
+ }
133
+
134
+ destroy () {
135
+ if (this.#closed) return
136
+ this.#cancelImmediate()
137
+ this.#drain()
138
+ this.#closed = true
139
+ this.#enabled = false
140
+ super.destroy()
141
+ }
142
+
143
+ #pump () {
144
+ if (this.#pumping) return
145
+ this.#pumping = true
146
+ try {
147
+ // Reserve a maximum-sized payload before advancing the iterator. HTTP callbacks release this budget.
148
+ while (this.#pendingBytes <= EVP_PAYLOAD_SIZE_LIMIT) {
149
+ if (!this.#pending) {
150
+ if ((!this.#flushRequested && !this.#closed) || this.#aggregator.size === 0) break
151
+ this.#flushRequested = false
152
+ const { full, degraded } = this.#aggregator.take()
153
+ for (const entry of full.values()) this.#pendingCount += entry.count
154
+ for (const entry of degraded.values()) this.#pendingCount += entry.count
155
+ this.#pending = iterateFlagEvaluationPayloads(full, degraded, this.#context, Date.now(), count => {
156
+ this.#pendingCount -= count
157
+ })
158
+ }
159
+ const next = this.#pending.next()
160
+ if (next.done) {
161
+ this.#pending = undefined
162
+ continue
163
+ }
164
+ const payload = next.value
165
+ const bytes = Buffer.byteLength(payload.encoded)
166
+ this.#pendingBytes += bytes
167
+ this._sendPayload(payload.encoded, payload.rows, delivered => {
168
+ this.#pendingBytes -= bytes
169
+ if (delivered) this.#onDelivered?.(payload.evaluations)
170
+ else recordDropped('delivery_failure', payload.evaluations)
171
+ this.#pump()
172
+ })
173
+ }
174
+ } finally {
175
+ this.#pumping = false
176
+ }
177
+ if (this.#closed && !this.#pending && this.#aggregator.size === 0 && this.#pendingBytes === 0) {
178
+ this.#onIdle?.()
179
+ this.#onIdle = undefined
180
+ }
181
+ }
182
+
183
+ /** @param {FlagEvaluationRoute} route */
184
+ #setRoute (route) {
185
+ const headers = route.headers ?? { [EVP_SUBDOMAIN_HEADER_NAME]: EVP_EVENT_PLATFORM_SUBDOMAIN }
186
+ const fallback = route.fallback && {
187
+ url: route.fallback.url,
188
+ endpoint: joinEVPProxyPath(route.fallback.basePath, FLAG_EVALUATION_ENDPOINT),
189
+ headers: route.fallback.headers ?? {},
190
+ agent: route.fallback.agent,
191
+ onFallback: route.fallback.onFallback,
192
+ onUnavailable: route.fallback.onUnavailable,
193
+ }
194
+ this._setRoutes({
195
+ url: route.url,
196
+ endpoint: joinEVPProxyPath(route.basePath, FLAG_EVALUATION_ENDPOINT),
197
+ headers,
198
+ agent: route.agent,
199
+ onFallback: route.onFallback,
200
+ onUnavailable: route.onUnavailable,
201
+ }, fallback)
202
+ }
203
+
204
+ #cancelImmediate () {
205
+ if (this.#immediate !== undefined) clearImmediate(this.#immediate)
206
+ this.#immediate = undefined
207
+ }
208
+
209
+ #drain () {
210
+ this.#immediate = undefined
211
+ const queue = this.#queue
212
+ this.#queue = []
213
+ for (const event of queue) {
214
+ this.#aggregator.add(event)
215
+ this.#onProcessed?.(1)
216
+ }
217
+ }
218
+
219
+ /** @param {string} reason */
220
+ #discardBuffered (reason) {
221
+ this.#cancelImmediate()
222
+ const count = this.#queue.length + this.#aggregator.clear() + this.#pendingCount
223
+ this.#pending?.return()
224
+ this.#pending = undefined
225
+ this.#pendingCount = 0
226
+ this.#flushRequested = false
227
+ this.#onProcessed?.(this.#queue.length)
228
+ this.#queue = []
229
+ recordDropped(reason, count)
230
+ }
231
+ }
232
+
233
+ module.exports = FlagEvaluationConsumer
@@ -0,0 +1,262 @@
1
+ 'use strict'
2
+
3
+ const { types: { isDate, isProxy } } = require('node:util')
4
+
5
+ const { normalizeTargetingKey } = require('./flag-evaluation-pii')
6
+
7
+ const MAX_CONTEXT_FIELDS = 256
8
+ const MAX_KEY_LENGTH = 256
9
+ const MAX_VALUE_LENGTH = 256
10
+ const MAX_LIST_ELEMENTS = 256
11
+ const MAX_STRUCTURE_PROPERTIES = 256
12
+ const MAX_SNAPSHOT_DEPTH = 4
13
+ const MAX_VISITED_NODES = MAX_CONTEXT_FIELDS * (MAX_SNAPSHOT_DEPTH + 1)
14
+ const OMIT_CONTEXT_VALUE = Symbol('omit context value')
15
+
16
+ /** @typedef {string | number | boolean | null} ContextScalar */
17
+ /** @typedef {Readonly<Record<string, ContextScalar>>} ContextSnapshot */
18
+ /** @typedef {Array<[string, ContextScalar]>} ContextEntries */
19
+ /**
20
+ * @typedef {object} Frame
21
+ * @property {object} container
22
+ * @property {string} prefix
23
+ * @property {number} depth
24
+ * @property {string[] | undefined} keys
25
+ * @property {number} index
26
+ * @property {number} limit
27
+ */
28
+
29
+ /**
30
+ * Only plain data records are supported; caller-defined behavior is not part of telemetry.
31
+ * Check proxies before any reflection, including revoked proxies.
32
+ *
33
+ * @param {unknown} value
34
+ * @returns {value is Record<string, unknown>}
35
+ */
36
+ function isRecord (value) {
37
+ if (value === null || typeof value !== 'object' || isProxy(value)) return false
38
+ const prototype = Object.getPrototypeOf(value)
39
+ return prototype === null || prototype === Object.prototype
40
+ }
41
+
42
+ /**
43
+ * Revalidate a hook-owned scalar snapshot without invoking caller behavior.
44
+ * Aggregation and serialization call this independently at their privacy boundaries.
45
+ *
46
+ * @param {unknown} value
47
+ * @returns {ContextSnapshot | undefined}
48
+ */
49
+ function validateContextSnapshot (value) {
50
+ if (!isRecord(value)) return
51
+ /** @type {Record<string, ContextScalar>} */
52
+ const attrs = Object.create(null)
53
+ let hasAttrs = false
54
+ for (const key of Object.keys(value)) {
55
+ const item = validatedContextValue(value, key)
56
+ if (item === OMIT_CONTEXT_VALUE) continue
57
+ attrs[key] = item
58
+ hasAttrs = true
59
+ }
60
+ return hasAttrs ? Object.freeze(attrs) : undefined
61
+ }
62
+
63
+ /**
64
+ * Share the privacy checks without making serialization allocate identity tuples.
65
+ * The sentinel distinguishes rejected values from a valid null scalar.
66
+ *
67
+ * @param {Record<string, unknown>} snapshot
68
+ * @param {string} key
69
+ * @returns {ContextScalar | typeof OMIT_CONTEXT_VALUE}
70
+ */
71
+ function validatedContextValue (snapshot, key) {
72
+ const descriptor = Object.getOwnPropertyDescriptor(snapshot, key)
73
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) return OMIT_CONTEXT_VALUE
74
+ const item = descriptor.value
75
+ if (normalizeTargetingKey(key) === undefined) return OMIT_CONTEXT_VALUE
76
+ if (item === null || typeof item === 'boolean' ||
77
+ (typeof item === 'number' && Number.isFinite(item)) ||
78
+ (typeof item === 'string' && normalizeTargetingKey(item) !== undefined)) return item
79
+ return OMIT_CONTEXT_VALUE
80
+ }
81
+
82
+ /**
83
+ * Validate once and order the scalar entries for aggregation identity. Returning
84
+ * tuples lets the complete key be JSON-encoded once, preserving scalar types and
85
+ * escaping delimiters without embedding an already-encoded context string.
86
+ *
87
+ * @param {unknown} value - Hook-owned snapshot, after queue handoff
88
+ * @returns {ContextEntries | undefined}
89
+ */
90
+ function validatedContextEntries (value) {
91
+ if (!isRecord(value)) return
92
+ /** @type {ContextEntries} */
93
+ const entries = []
94
+ for (const key of Object.keys(value).sort()) {
95
+ const item = validatedContextValue(value, key)
96
+ if (item !== OMIT_CONTEXT_VALUE) entries.push([key, item])
97
+ }
98
+ return entries.length > 0 ? entries : undefined
99
+ }
100
+
101
+ /**
102
+ * Materialize owned, immutable attributes only when they will be retained or sent.
103
+ * Callers must obtain entries from validatedContextEntries, never caller-owned data.
104
+ *
105
+ * @param {ContextEntries | undefined} entries
106
+ * @returns {ContextSnapshot | undefined}
107
+ */
108
+ function snapshotFromEntries (entries) {
109
+ if (entries === undefined) return
110
+ /** @type {Record<string, ContextScalar>} */
111
+ const attrs = Object.create(null)
112
+ for (const [key, value] of entries) attrs[key] = value
113
+ return Object.freeze(attrs)
114
+ }
115
+
116
+ /**
117
+ * Retain only a capped set of property names, in JavaScript's natural key order.
118
+ *
119
+ * Object.keys must enumerate the complete object: JS has no bounded own-key API.
120
+ * The key-list allocation is proportional to object width, unlike indexed array
121
+ * traversal. Explicit traversal, frame retention, and output are independently capped.
122
+ *
123
+ * @param {object} container
124
+ * @param {string} prefix
125
+ * @param {number} depth
126
+ * @param {Set<string>} reasons
127
+ * @returns {Frame}
128
+ */
129
+ function createFrame (container, prefix, depth, reasons) {
130
+ let keys
131
+ let limit
132
+ if (Array.isArray(container)) {
133
+ limit = Math.min(container.length, MAX_LIST_ELEMENTS)
134
+ if (container.length > limit) reasons.add('max_list_elements')
135
+ } else {
136
+ const allKeys = Object.keys(container)
137
+ if (depth === -1) {
138
+ const excluded = Object.getOwnPropertyDescriptor(container, 'targetingKey')?.enumerable === true
139
+ if (allKeys.length - Number(excluded) > MAX_STRUCTURE_PROPERTIES) reasons.add('max_context_fields')
140
+ keys = allKeys.slice(0, MAX_STRUCTURE_PROPERTIES + 1)
141
+ .filter(key => key !== 'targetingKey').slice(0, MAX_STRUCTURE_PROPERTIES)
142
+ } else {
143
+ if (allKeys.length > MAX_STRUCTURE_PROPERTIES) reasons.add('max_structure_properties')
144
+ keys = allKeys.slice(0, MAX_STRUCTURE_PROPERTIES)
145
+ }
146
+ limit = keys.length
147
+ }
148
+ return { container, prefix, depth, keys, index: 0, limit }
149
+ }
150
+
151
+ /**
152
+ * Flatten consented context into a frozen, scalar-only snapshot before enqueue.
153
+ * No getters, proxy traps, iterators, or caller serialization hooks are invoked.
154
+ * The caller must check capacity and consent before calling this function.
155
+ *
156
+ * @param {unknown} context - Caller-owned OpenFeature context
157
+ * @returns {{ attrs: ContextSnapshot, reasons: Set<string> }}
158
+ */
159
+ function snapshotEvaluationContext (context) {
160
+ /** @type {Record<string, ContextScalar>} */
161
+ const attrs = Object.create(null)
162
+ const reasons = new Set()
163
+ if (!isRecord(context)) {
164
+ if (context !== null && context !== undefined) reasons.add('unsupported_type')
165
+ return { attrs: Object.freeze(attrs), reasons }
166
+ }
167
+
168
+ const root = /** @type {object} */ (context)
169
+ const ancestors = new WeakSet([root])
170
+ const stack = [createFrame(root, '', -1, reasons)]
171
+ let visited = 0
172
+ let fields = 0
173
+
174
+ while (stack.length > 0) {
175
+ const frame = /** @type {Frame} */ (stack.at(-1))
176
+ if (frame.index === frame.limit) {
177
+ ancestors.delete(frame.container)
178
+ stack.pop()
179
+ continue
180
+ }
181
+ if (fields === MAX_CONTEXT_FIELDS) {
182
+ reasons.add('max_context_fields')
183
+ break
184
+ }
185
+ if (visited === MAX_VISITED_NODES) {
186
+ reasons.add('max_visited_nodes')
187
+ break
188
+ }
189
+ visited++
190
+ const key = frame.keys ? frame.keys[frame.index++] : String(frame.index++)
191
+ const prefix = frame.depth === -1 ? key : frame.prefix + '.' + key
192
+ if (prefix.length > MAX_KEY_LENGTH) {
193
+ reasons.add('max_key_length')
194
+ continue
195
+ }
196
+ if (normalizeTargetingKey(prefix) === undefined) {
197
+ reasons.add('invalid_encoding')
198
+ continue
199
+ }
200
+
201
+ const descriptor = Object.getOwnPropertyDescriptor(frame.container, key)
202
+ if (!descriptor?.enumerable) continue
203
+ if (!Object.hasOwn(descriptor, 'value')) {
204
+ reasons.add('unsupported_type')
205
+ continue
206
+ }
207
+ /** @type {unknown} */
208
+ let value = descriptor.value
209
+ if (typeof value === 'string') {
210
+ if (value.length > MAX_VALUE_LENGTH) {
211
+ reasons.add('max_value_length')
212
+ continue
213
+ }
214
+ if (normalizeTargetingKey(value) === undefined) {
215
+ reasons.add('invalid_encoding')
216
+ continue
217
+ }
218
+ } else if (value !== null && typeof value === 'object') {
219
+ if (isProxy(value)) {
220
+ reasons.add('unsupported_type')
221
+ continue
222
+ }
223
+ if (isDate(value)) {
224
+ if (!Number.isFinite(Date.prototype.getTime.call(value))) {
225
+ reasons.add('unsupported_type')
226
+ continue
227
+ }
228
+ value = Date.prototype.toISOString.call(value)
229
+ } else {
230
+ if (!Array.isArray(value) && !isRecord(value)) {
231
+ reasons.add('unsupported_type')
232
+ continue
233
+ }
234
+ const depth = frame.depth + 1
235
+ if (depth >= MAX_SNAPSHOT_DEPTH) {
236
+ reasons.add('max_snapshot_depth')
237
+ continue
238
+ }
239
+ if (ancestors.has(value)) {
240
+ reasons.add('cycle')
241
+ continue
242
+ }
243
+ ancestors.add(value)
244
+ stack.push(createFrame(value, prefix, depth, reasons))
245
+ continue
246
+ }
247
+ } else if (
248
+ value !== null && typeof value !== 'boolean' &&
249
+ (typeof value !== 'number' || !Number.isFinite(value))
250
+ ) {
251
+ reasons.add('unsupported_type')
252
+ continue
253
+ }
254
+
255
+ if (!Object.hasOwn(attrs, prefix)) fields++
256
+ attrs[prefix] = /** @type {ContextScalar} */ (value)
257
+ }
258
+
259
+ return { attrs: Object.freeze(attrs), reasons }
260
+ }
261
+
262
+ module.exports = { snapshotEvaluationContext, validatedContextEntries, snapshotFromEntries, validateContextSnapshot }