dd-trace 6.19.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 (77) hide show
  1. package/index.d.ts +163 -22
  2. package/package.json +2 -2
  3. package/packages/datadog-esbuild/index.js +9 -6
  4. package/packages/datadog-instrumentations/src/ai.js +26 -0
  5. package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
  6. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -7
  7. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +2 -2
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +25 -0
  9. package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
  10. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  11. package/packages/dd-trace/src/config/remote_config.js +12 -0
  12. package/packages/dd-trace/src/config/supported-configurations.json +21 -0
  13. package/packages/dd-trace/src/debugger/constants.js +13 -4
  14. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +14 -1
  15. package/packages/dd-trace/src/debugger/devtools_client/condition.js +109 -6
  16. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -0
  17. package/packages/dd-trace/src/debugger/devtools_client/index.js +65 -6
  18. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +22 -10
  19. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +12 -8
  20. package/packages/dd-trace/src/debugger/devtools_client/send.js +14 -0
  21. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +43 -5
  22. package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +3 -8
  23. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +2 -122
  24. package/packages/dd-trace/src/debugger/guardrail-metrics.js +0 -2
  25. package/packages/dd-trace/src/debugger/index.js +60 -28
  26. package/packages/dd-trace/src/debugger/inspect-segment.js +92 -23
  27. package/packages/dd-trace/src/debugger/pause-duration-histogram.js +74 -0
  28. package/packages/dd-trace/src/debugger/probe_sampler.js +192 -52
  29. package/packages/dd-trace/src/debugger/redaction.js +139 -0
  30. package/packages/dd-trace/src/knuth-hash.js +17 -0
  31. package/packages/dd-trace/src/llmobs/constants/tags.js +2 -0
  32. package/packages/dd-trace/src/llmobs/experiments/client.js +66 -2
  33. package/packages/dd-trace/src/llmobs/experiments/evaluator.js +138 -0
  34. package/packages/dd-trace/src/llmobs/experiments/experiment.js +213 -90
  35. package/packages/dd-trace/src/llmobs/experiments/index.js +40 -1
  36. package/packages/dd-trace/src/llmobs/experiments/noop.js +53 -2
  37. package/packages/dd-trace/src/llmobs/experiments/remote-evaluator.js +110 -0
  38. package/packages/dd-trace/src/llmobs/experiments/util.js +34 -6
  39. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +2 -2
  40. package/packages/dd-trace/src/llmobs/sdk.js +19 -3
  41. package/packages/dd-trace/src/llmobs/span_processor.js +42 -26
  42. package/packages/dd-trace/src/llmobs/tagger.js +35 -0
  43. package/packages/dd-trace/src/llmobs/writers/base.js +1 -6
  44. package/packages/dd-trace/src/openfeature/constants/constants.js +10 -0
  45. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -1
  46. package/packages/dd-trace/src/openfeature/writers/base.js +39 -18
  47. package/packages/dd-trace/src/openfeature/writers/flag-eval-evp-hook.js +105 -0
  48. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-aggregation.js +190 -0
  49. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-consumer.js +233 -0
  50. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-context.js +262 -0
  51. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-payload.js +181 -0
  52. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-pii.js +108 -0
  53. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-telemetry.js +154 -0
  54. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-worker.js +77 -0
  55. package/packages/dd-trace/src/openfeature/writers/flag-evaluations.js +398 -0
  56. package/packages/dd-trace/src/openfeature/writers/util.js +5 -4
  57. package/packages/dd-trace/src/opentelemetry/context_manager.js +3 -0
  58. package/packages/dd-trace/src/opentelemetry/span_context.js +6 -2
  59. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -0
  60. package/packages/dd-trace/src/opentelemetry/tracer.js +16 -18
  61. package/packages/dd-trace/src/opentracing/propagation/text_map.js +116 -25
  62. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +80 -16
  63. package/packages/dd-trace/src/opentracing/span.js +14 -9
  64. package/packages/dd-trace/src/opentracing/tracer.js +10 -3
  65. package/packages/dd-trace/src/otel-sampling.js +171 -0
  66. package/packages/dd-trace/src/plugins/index.js +0 -1
  67. package/packages/dd-trace/src/priority_sampler.js +155 -33
  68. package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
  69. package/packages/dd-trace/src/sampler.js +2 -5
  70. package/packages/dd-trace/src/sampling_rule.js +4 -8
  71. package/packages/dd-trace/src/span_format.js +6 -0
  72. package/packages/dd-trace/src/span_processor.js +26 -2
  73. package/packages/dd-trace/src/standalone/index.js +5 -4
  74. package/packages/dd-trace/src/standalone/tracesource_priority_sampler.js +25 -5
  75. package/packages/dd-trace/src/telemetry/metrics.js +4 -3
  76. package/packages/datadog-instrumentations/src/postgres.js +0 -7
  77. package/packages/datadog-instrumentations/src/supabase.js +0 -15
@@ -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 }
@@ -0,0 +1,181 @@
1
+ 'use strict'
2
+
3
+ const { EVP_EVENT_SIZE_LIMIT, EVP_PAYLOAD_SIZE_LIMIT } = require('../constants/constants')
4
+ const { validateContextSnapshot } = require('./flag-evaluation-context')
5
+ const { prefixedTargetingKeyDigest, normalizeTargetingKey, protectedErrorCode } = require('./flag-evaluation-pii')
6
+ const { recordDegraded, recordDropped, recordPayloadSplit } = require('./flag-evaluation-telemetry')
7
+
8
+ /** @typedef {import('./flag-evaluation-aggregation').AggregationEntry} AggregationEntry */
9
+ /** @typedef {import('./flag-evaluation-context').ContextSnapshot} ContextSnapshot */
10
+ /**
11
+ * @typedef {object} FlagEvaluationBatchContext
12
+ * @property {string} service
13
+ * @property {string} [env]
14
+ * @property {string} [version]
15
+ */
16
+ /**
17
+ * @typedef {object} FlagEvaluationRow
18
+ * @property {number} timestamp
19
+ * @property {{ key: string }} flag
20
+ * @property {number} first_evaluation
21
+ * @property {number} last_evaluation
22
+ * @property {number} evaluation_count
23
+ * @property {boolean} [runtime_default_used]
24
+ * @property {string} [targeting_key]
25
+ * @property {{ evaluation: ContextSnapshot }} [context]
26
+ * @property {{ key: string }} [variant]
27
+ * @property {{ key: string }} [allocation]
28
+ * @property {{ message: string }} [error]
29
+ */
30
+ /** @typedef {{ encoded: string, rows: number, evaluations: number }} EncodedFlagEvaluationPayload */
31
+
32
+ /**
33
+ * @param {AggregationEntry} entry
34
+ * @param {number} timestamp
35
+ * @param {boolean} degraded
36
+ * @returns {FlagEvaluationRow | undefined}
37
+ */
38
+ function makeRow (entry, timestamp, degraded) {
39
+ const flagKey = normalizeTargetingKey(entry.flagKey)
40
+ if (flagKey === undefined || !Number.isSafeInteger(entry.first) || !Number.isSafeInteger(entry.last) ||
41
+ !Number.isSafeInteger(entry.count) || entry.count < 1) return
42
+ /** @type {FlagEvaluationRow} */
43
+ const row = {
44
+ timestamp,
45
+ flag: { key: flagKey },
46
+ first_evaluation: entry.first,
47
+ last_evaluation: entry.last,
48
+ evaluation_count: entry.count,
49
+ }
50
+ if (entry.runtimeDefault === true) row.runtime_default_used = true
51
+ const variant = normalizeTargetingKey(entry.variant)
52
+ const allocation = normalizeTargetingKey(entry.allocation)
53
+ if (variant) row.variant = { key: variant }
54
+ if (allocation) row.allocation = { key: allocation }
55
+ const error = protectedErrorCode(entry.error)
56
+ if (error !== undefined) row.error = { message: error }
57
+ if (!degraded) {
58
+ const targetingKey = entry.consent === true
59
+ ? normalizeTargetingKey(entry.rawTargetingKey)
60
+ : prefixedTargetingKeyDigest(entry.rawTargetingKey)
61
+ if (targetingKey !== undefined) row.targeting_key = targetingKey
62
+ const attrs = entry.consent === true ? validateContextSnapshot(entry.attrs) : undefined
63
+ if (attrs !== undefined) row.context = { evaluation: attrs }
64
+ }
65
+ return row
66
+ }
67
+
68
+ /**
69
+ * Advance serialization only when the transport has room for another complete envelope.
70
+ * Takes ownership of the detached aggregate maps and clears them when iteration starts.
71
+ *
72
+ * @param {Map<string, AggregationEntry>} full
73
+ * @param {Map<string, AggregationEntry>} degraded
74
+ * @param {FlagEvaluationBatchContext} context
75
+ * @param {number} timestamp
76
+ * @param {(count: number) => void} [onConsumed] - Transfer observation ownership on encoding or discard
77
+ * @yields {EncodedFlagEvaluationPayload} One envelope bounded by the existing EVP payload limit.
78
+ */
79
+ function * iterateFlagEvaluationPayloads (full, degraded, context, timestamp, onConsumed) {
80
+ const prefix = '{"context":' + JSON.stringify(context) + ',"flagEvaluations":['
81
+ const suffix = ']}'
82
+ /** @type {string[]} */
83
+ let encodedRows = []
84
+ let evaluations = 0
85
+ let payloadCount = 0
86
+ let size = Buffer.byteLength(prefix) + Buffer.byteLength(suffix)
87
+
88
+ const close = () => {
89
+ const payload = { encoded: prefix + encodedRows.join(',') + suffix, rows: encodedRows.length, evaluations }
90
+ if (payloadCount > 0) recordPayloadSplit()
91
+ payloadCount++
92
+ evaluations = 0
93
+ encodedRows = []
94
+ size = Buffer.byteLength(prefix) + Buffer.byteLength(suffix)
95
+ return payload
96
+ }
97
+
98
+ /**
99
+ * @param {string} reason
100
+ * @param {number} count
101
+ */
102
+ const drop = (reason, count) => {
103
+ onConsumed?.(count)
104
+ recordDropped(reason, count)
105
+ }
106
+
107
+ /** @type {Array<[AggregationEntry, boolean]>} */
108
+ const entries = []
109
+ for (const entry of full.values()) entries.push([entry, false])
110
+ for (const entry of degraded.values()) entries.push([entry, true])
111
+
112
+ // Lookup keys are no longer needed after aggregation. Pop entries in their original
113
+ // order so a paused flush does not retain entries that have already been encoded.
114
+ full.clear()
115
+ degraded.clear()
116
+ entries.reverse()
117
+ while (entries.length > 0) {
118
+ const [entry, aggregateDegraded] = /** @type {[AggregationEntry, boolean]} */ (entries.pop())
119
+ const row = makeRow(entry, timestamp, aggregateDegraded)
120
+ if (row === undefined) {
121
+ drop('serialization_error', entry.count)
122
+ continue
123
+ }
124
+ let serializedRow = row
125
+ let encoded
126
+ try {
127
+ encoded = JSON.stringify(serializedRow)
128
+ } catch {
129
+ drop('serialization_error', serializedRow.evaluation_count)
130
+ continue
131
+ }
132
+
133
+ let canDegrade = serializedRow.targeting_key !== undefined || serializedRow.context !== undefined
134
+ const degrade = () => {
135
+ if (!canDegrade) return false
136
+ serializedRow = { ...serializedRow }
137
+ delete serializedRow.targeting_key
138
+ delete serializedRow.context
139
+ encoded = JSON.stringify(serializedRow)
140
+ recordDegraded('payload_limit', serializedRow.evaluation_count)
141
+ canDegrade = false
142
+ return true
143
+ }
144
+
145
+ try {
146
+ if (Buffer.byteLength(encoded) > EVP_EVENT_SIZE_LIMIT) degrade()
147
+ } catch {
148
+ drop('serialization_error', serializedRow.evaluation_count)
149
+ continue
150
+ }
151
+ if (Buffer.byteLength(encoded) > EVP_EVENT_SIZE_LIMIT) {
152
+ drop('payload_limit', serializedRow.evaluation_count)
153
+ continue
154
+ }
155
+
156
+ let addition = Buffer.byteLength(encoded) + Number(encodedRows.length > 0)
157
+ if (size + addition > EVP_PAYLOAD_SIZE_LIMIT && encodedRows.length > 0) {
158
+ yield close()
159
+ addition = Buffer.byteLength(encoded)
160
+ }
161
+ if (size + addition > EVP_PAYLOAD_SIZE_LIMIT) {
162
+ try {
163
+ if (degrade()) addition = Buffer.byteLength(encoded)
164
+ } catch {
165
+ drop('serialization_error', serializedRow.evaluation_count)
166
+ continue
167
+ }
168
+ }
169
+ if (size + addition > EVP_PAYLOAD_SIZE_LIMIT) {
170
+ drop('payload_limit', serializedRow.evaluation_count)
171
+ continue
172
+ }
173
+ encodedRows.push(encoded)
174
+ onConsumed?.(serializedRow.evaluation_count)
175
+ evaluations += serializedRow.evaluation_count
176
+ size += addition
177
+ }
178
+ if (encodedRows.length > 0) yield close()
179
+ }
180
+
181
+ module.exports = { iterateFlagEvaluationPayloads }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ const { hashTargetingKey: digestTargetingKey } = require('../encoding')
4
+
5
+ // Keep this vocabulary explicit: an upstream enum addition needs a privacy review.
6
+ const ERROR_CODES = new Set([
7
+ 'PROVIDER_NOT_READY',
8
+ 'PROVIDER_FATAL',
9
+ 'FLAG_NOT_FOUND',
10
+ 'PARSE_ERROR',
11
+ 'TYPE_MISMATCH',
12
+ 'TARGETING_KEY_MISSING',
13
+ 'INVALID_CONTEXT',
14
+ 'GENERAL',
15
+ ])
16
+
17
+ /**
18
+ * Validate targeting text before UTF-8 encoding, which would repair lone surrogates.
19
+ * Empty text is valid and distinct from a missing key. Do not coerce customer input.
20
+ *
21
+ * @param {unknown} value - Evaluation targeting key
22
+ * @returns {string | undefined}
23
+ */
24
+ function normalizeTargetingKey (value) {
25
+ if (typeof value !== 'string') return
26
+
27
+ for (let i = 0; i < value.length; i++) {
28
+ const code = value.charCodeAt(i)
29
+ if (code >= 0xD8_00 && code <= 0xDB_FF) {
30
+ if (++i === value.length) return
31
+ const low = value.charCodeAt(i)
32
+ if (low < 0xDC_00 || low > 0xDF_FF) return
33
+ } else if (code >= 0xDC_00 && code <= 0xDF_FF) {
34
+ return
35
+ }
36
+ }
37
+
38
+ return value
39
+ }
40
+
41
+ /**
42
+ * Apply EVP's protected targeting-key policy using the shared SHA-256 primitive.
43
+ * The cross-SDK contract deliberately uses an unsalted, stable digest for subject counts.
44
+ * This is pseudonymization, not anonymity: guessable keys remain guessable and equal
45
+ * keys produce equal hashes across services and organizations. An org-scoped salt
46
+ * requires a coordinated configuration/backend contract, not a JS-only secret.
47
+ * This runs after queue handoff; hashing must not move onto the evaluation hot path.
48
+ *
49
+ * @param {unknown} value - Raw evaluation targeting key, never a previously emitted hash
50
+ * @returns {string | undefined}
51
+ */
52
+ function prefixedTargetingKeyDigest (value) {
53
+ const key = normalizeTargetingKey(value)
54
+ if (key === undefined || key === '') return key
55
+ return 'sha256_' + digestTargetingKey(key)
56
+ }
57
+
58
+ /**
59
+ * Preserve only approved OpenFeature codes on the wire, in every consent mode.
60
+ * Absence means no error; malformed or unknown supplied codes become GENERAL.
61
+ *
62
+ * @param {unknown} value - OpenFeature errorCode, never errorMessage
63
+ * @returns {string | undefined}
64
+ */
65
+ function protectedErrorCode (value) {
66
+ if (value === undefined || value === null || value === '') return
67
+ return typeof value === 'string' && ERROR_CODES.has(value) ? value : 'GENERAL'
68
+ }
69
+
70
+ /**
71
+ * Optional metadata dimensions omit empty text; targeting keys deliberately preserve it.
72
+ *
73
+ * @param {unknown} value
74
+ * @returns {string | undefined}
75
+ */
76
+ function optionalKey (value) {
77
+ const key = normalizeTargetingKey(value)
78
+ return key === undefined || key.length === 0 ? undefined : key
79
+ }
80
+
81
+ /**
82
+ * Shape observations at both admission boundaries; each caller counts omitted targeting keys.
83
+ *
84
+ * @param {import('./flag-evaluation-aggregation').FlagEvaluationEvent} event
85
+ * @param {string | undefined} targetingKey - Targeting key already validated by the caller
86
+ */
87
+ function normalizeFlagEvaluationEvent (event, targetingKey) {
88
+ const consent = event.observeFullEvaluationData === true
89
+ return {
90
+ flagKey: normalizeTargetingKey(event.flagKey),
91
+ variant: optionalKey(event.variant),
92
+ allocationKey: optionalKey(event.allocationKey),
93
+ runtimeDefault: event.runtimeDefault === true,
94
+ errorCode: protectedErrorCode(event.errorCode),
95
+ targetingKey,
96
+ attrs: consent ? event.attrs : undefined,
97
+ observeFullEvaluationData: consent,
98
+ timestamp: typeof event.timestamp === 'number' ? event.timestamp : NaN,
99
+ }
100
+ }
101
+
102
+ module.exports = {
103
+ normalizeFlagEvaluationEvent,
104
+ normalizeTargetingKey,
105
+ optionalKey,
106
+ prefixedTargetingKeyDigest,
107
+ protectedErrorCode,
108
+ }
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+
3
+ let metrics
4
+ /** @type {Int32Array | undefined} */
5
+ let workerState
6
+
7
+ // Namespace resets clear points, not metric instances. Names and reasons are fixed below.
8
+ /** @type {Map<string, { inc: (value: number) => void }>} */
9
+ const countMetrics = new Map()
10
+
11
+ const DROP_REASONS = new Set([
12
+ 'pre_queue_overflow',
13
+ 'queue_overflow',
14
+ 'closed',
15
+ 'unavailable',
16
+ 'degraded_cap',
17
+ 'payload_limit',
18
+ 'serialization_error',
19
+ // Discarded after failed delivery; an ambiguous response does not prove the receiver lost the batch.
20
+ 'delivery_failure',
21
+ 'worker_failure',
22
+ 'shutdown_timeout',
23
+ ])
24
+ const DEGRADE_REASONS = new Set(['cardinality_cap', 'payload_limit'])
25
+ const CONTEXT_REASONS = new Set([
26
+ 'max_context_fields',
27
+ 'max_key_length',
28
+ 'max_value_length',
29
+ 'max_list_elements',
30
+ 'max_structure_properties',
31
+ 'max_snapshot_depth',
32
+ 'max_visited_nodes',
33
+ 'cycle',
34
+ 'invalid_encoding',
35
+ 'unsupported_type',
36
+ 'snapshot_error',
37
+ ])
38
+
39
+ // Slot 0 owns admitted input until aggregation; slot 1 owns all accepted observations until delivery/drop.
40
+ // Fixed metric slots follow. Only the application drains them; no message per event or flush.
41
+ /** @type {Array<[string, string | undefined]>} */
42
+ const metricKeys = [
43
+ ...[...DROP_REASONS].map(reason => /** @type {[string, string]} */ (['flagevaluation.rows.dropped', reason])),
44
+ ...[...DEGRADE_REASONS].map(reason => /** @type {[string, string]} */ (['flagevaluation.rows.degraded', reason])),
45
+ ...[...CONTEXT_REASONS].map(reason => /** @type {[string, string]} */ (['flagevaluation.context.truncated', reason])),
46
+ ['flagevaluation.payload.splits', undefined],
47
+ ['flagevaluation.targeting_key.omitted', 'invalid'],
48
+ ['flagevaluation.hook.errors', undefined],
49
+ ]
50
+ const metricSlots = new Map(metricKeys.map(([name, reason], index) => [name + ':' + reason, index + 2]))
51
+
52
+ /** @param {Int32Array} state */
53
+ function configureWorkerTelemetry (state) {
54
+ workerState = state
55
+ }
56
+
57
+ function createWorkerState () {
58
+ return new Int32Array(new SharedArrayBuffer((metricKeys.length + 2) * Int32Array.BYTES_PER_ELEMENT))
59
+ }
60
+
61
+ /** @param {Int32Array} state */
62
+ function collectWorkerTelemetry (state) {
63
+ for (let i = 0; i < metricKeys.length; i++) {
64
+ const value = Atomics.exchange(state, i + 2, 0)
65
+ const [name, reason] = metricKeys[i]
66
+ count(name, value, reason)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Best-effort telemetry must never affect an evaluation or writer drain.
72
+ *
73
+ * @param {string} name - Bounded metric name
74
+ * @param {number} value - Count to add
75
+ * @param {string} [reason] - Bounded reason tag
76
+ */
77
+ function count (name, value, reason) {
78
+ if (!Number.isSafeInteger(value) || value <= 0) return
79
+ if (workerState) {
80
+ const slot = /** @type {number} */ (metricSlots.get(name + ':' + reason))
81
+ // Saturate instead of wrapping if the application stops collecting metrics for a long time.
82
+ let previous = Atomics.load(workerState, slot)
83
+ while (Atomics.compareExchange(workerState, slot, previous,
84
+ Math.min(0x7F_FF_FF_FF, previous + value)) !== previous) {
85
+ previous = Atomics.load(workerState, slot)
86
+ }
87
+ return
88
+ }
89
+ try {
90
+ const id = name + ':' + reason
91
+ let metric = countMetrics.get(id)
92
+ if (metric === undefined) {
93
+ metrics ??= require('../../telemetry/metrics').manager.namespace('general')
94
+ metric = /** @type {{ inc: (value: number) => void }} */ (
95
+ metrics.count(name, reason === undefined ? undefined : { reason })
96
+ )
97
+ countMetrics.set(id, metric)
98
+ }
99
+ metric.inc(value)
100
+ } catch {}
101
+ }
102
+
103
+ /**
104
+ * @param {string} reason
105
+ * @param {number} [value]
106
+ */
107
+ function recordDropped (reason, value = 1) {
108
+ if (!DROP_REASONS.has(reason)) return
109
+ if (workerState && Number.isSafeInteger(value) && value > 0) Atomics.sub(workerState, 1, value)
110
+ count('flagevaluation.rows.dropped', value, reason)
111
+ }
112
+
113
+ /**
114
+ * @param {string} reason
115
+ * @param {number} [value]
116
+ */
117
+ function recordDegraded (reason, value = 1) {
118
+ if (DEGRADE_REASONS.has(reason)) count('flagevaluation.rows.degraded', value, reason)
119
+ }
120
+
121
+ /** @param {number} [value] */
122
+ function recordPayloadSplit (value = 1) {
123
+ count('flagevaluation.payload.splits', value)
124
+ }
125
+
126
+ /**
127
+ * @param {string} reason
128
+ * @param {number} [value]
129
+ */
130
+ function recordContextTruncated (reason, value = 1) {
131
+ if (CONTEXT_REASONS.has(reason)) count('flagevaluation.context.truncated', value, reason)
132
+ }
133
+
134
+ /** @param {number} [value] */
135
+ function recordTargetingKeyOmitted (value = 1) {
136
+ count('flagevaluation.targeting_key.omitted', value, 'invalid')
137
+ }
138
+
139
+ /** @param {number} [value] */
140
+ function recordHookError (value = 1) {
141
+ count('flagevaluation.hook.errors', value)
142
+ }
143
+
144
+ module.exports = {
145
+ collectWorkerTelemetry,
146
+ configureWorkerTelemetry,
147
+ createWorkerState,
148
+ recordContextTruncated,
149
+ recordDegraded,
150
+ recordDropped,
151
+ recordHookError,
152
+ recordPayloadSplit,
153
+ recordTargetingKeyOmitted,
154
+ }