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.
- package/LICENSE-3rdparty.csv +1 -0
- package/ci/vitest-no-worker-init-setup.mjs +22 -12
- package/index.d.ts +166 -24
- package/package.json +7 -7
- package/packages/datadog-esbuild/index.js +9 -6
- package/packages/datadog-instrumentations/src/ai.js +26 -0
- package/packages/datadog-instrumentations/src/anthropic.js +111 -9
- package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
- package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
- package/packages/datadog-instrumentations/src/cucumber.js +40 -4
- 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-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
- package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
- package/packages/datadog-instrumentations/src/jest.js +3 -10
- package/packages/datadog-instrumentations/src/playwright.js +26 -0
- package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
- package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
- package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
- package/packages/datadog-plugin-cucumber/src/index.js +19 -4
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
- package/packages/datadog-plugin-fetch/src/index.js +3 -0
- package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
- package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
- package/packages/datadog-plugin-undici/src/index.js +5 -0
- package/packages/dd-trace/src/aiguard/client.js +38 -19
- package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
- package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
- package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
- package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
- package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
- package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -4
- package/packages/dd-trace/src/config/remote_config.js +12 -0
- package/packages/dd-trace/src/config/supported-configurations.json +26 -5
- package/packages/dd-trace/src/constants.js +2 -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 +33 -8
- 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/gen-ai-tags.js +127 -0
- package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
- package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
- package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
- package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
- package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
- package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
- package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
- package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
- package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
- package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
- package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +15 -2
- package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
- package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
- package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
- package/packages/dd-trace/src/llmobs/sdk.js +19 -3
- package/packages/dd-trace/src/llmobs/span_processor.js +52 -91
- package/packages/dd-trace/src/llmobs/tagger.js +37 -36
- 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/index.js +5 -0
- 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/plugins/util/test.js +4 -0
- package/packages/dd-trace/src/priority_sampler.js +155 -33
- package/packages/dd-trace/src/profiler.js +51 -5
- package/packages/dd-trace/src/profiling/profiler.js +24 -20
- package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
- package/packages/dd-trace/src/ritm.js +12 -9
- 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 +32 -3
- 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/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
- package/packages/datadog-instrumentations/src/postgres.js +0 -7
- 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 }
|