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.
- package/index.d.ts +163 -22
- package/package.json +2 -2
- package/packages/datadog-esbuild/index.js +9 -6
- package/packages/datadog-instrumentations/src/ai.js +26 -0
- package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
- package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -7
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +2 -2
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +25 -0
- package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
- package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
- package/packages/dd-trace/src/config/remote_config.js +12 -0
- package/packages/dd-trace/src/config/supported-configurations.json +21 -0
- package/packages/dd-trace/src/debugger/constants.js +13 -4
- package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +14 -1
- package/packages/dd-trace/src/debugger/devtools_client/condition.js +109 -6
- package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -0
- package/packages/dd-trace/src/debugger/devtools_client/index.js +65 -6
- package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +22 -10
- package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +12 -8
- package/packages/dd-trace/src/debugger/devtools_client/send.js +14 -0
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +43 -5
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +3 -8
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +2 -122
- package/packages/dd-trace/src/debugger/guardrail-metrics.js +0 -2
- package/packages/dd-trace/src/debugger/index.js +60 -28
- package/packages/dd-trace/src/debugger/inspect-segment.js +92 -23
- package/packages/dd-trace/src/debugger/pause-duration-histogram.js +74 -0
- package/packages/dd-trace/src/debugger/probe_sampler.js +192 -52
- package/packages/dd-trace/src/debugger/redaction.js +139 -0
- package/packages/dd-trace/src/knuth-hash.js +17 -0
- package/packages/dd-trace/src/llmobs/constants/tags.js +2 -0
- package/packages/dd-trace/src/llmobs/experiments/client.js +66 -2
- package/packages/dd-trace/src/llmobs/experiments/evaluator.js +138 -0
- package/packages/dd-trace/src/llmobs/experiments/experiment.js +213 -90
- package/packages/dd-trace/src/llmobs/experiments/index.js +40 -1
- package/packages/dd-trace/src/llmobs/experiments/noop.js +53 -2
- package/packages/dd-trace/src/llmobs/experiments/remote-evaluator.js +110 -0
- package/packages/dd-trace/src/llmobs/experiments/util.js +34 -6
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +2 -2
- package/packages/dd-trace/src/llmobs/sdk.js +19 -3
- package/packages/dd-trace/src/llmobs/span_processor.js +42 -26
- package/packages/dd-trace/src/llmobs/tagger.js +35 -0
- package/packages/dd-trace/src/llmobs/writers/base.js +1 -6
- package/packages/dd-trace/src/openfeature/constants/constants.js +10 -0
- package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -1
- package/packages/dd-trace/src/openfeature/writers/base.js +39 -18
- package/packages/dd-trace/src/openfeature/writers/flag-eval-evp-hook.js +105 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-aggregation.js +190 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-consumer.js +233 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-context.js +262 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-payload.js +181 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-pii.js +108 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-telemetry.js +154 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluation-worker.js +77 -0
- package/packages/dd-trace/src/openfeature/writers/flag-evaluations.js +398 -0
- package/packages/dd-trace/src/openfeature/writers/util.js +5 -4
- package/packages/dd-trace/src/opentelemetry/context_manager.js +3 -0
- package/packages/dd-trace/src/opentelemetry/span_context.js +6 -2
- package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -0
- package/packages/dd-trace/src/opentelemetry/tracer.js +16 -18
- package/packages/dd-trace/src/opentracing/propagation/text_map.js +116 -25
- package/packages/dd-trace/src/opentracing/propagation/tracestate.js +80 -16
- package/packages/dd-trace/src/opentracing/span.js +14 -9
- package/packages/dd-trace/src/opentracing/tracer.js +10 -3
- package/packages/dd-trace/src/otel-sampling.js +171 -0
- package/packages/dd-trace/src/plugins/index.js +0 -1
- package/packages/dd-trace/src/priority_sampler.js +155 -33
- package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
- package/packages/dd-trace/src/sampler.js +2 -5
- package/packages/dd-trace/src/sampling_rule.js +4 -8
- package/packages/dd-trace/src/span_format.js +6 -0
- package/packages/dd-trace/src/span_processor.js +26 -2
- package/packages/dd-trace/src/standalone/index.js +5 -4
- package/packages/dd-trace/src/standalone/tracesource_priority_sampler.js +25 -5
- package/packages/dd-trace/src/telemetry/metrics.js +4 -3
- package/packages/datadog-instrumentations/src/postgres.js +0 -7
- 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
|
+
}
|