@netgreener/runtime 0.1.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/CHANGELOG.md +16 -0
- package/README.md +28 -0
- package/TUTORIAL.md +495 -0
- package/dist/aggregator.d.ts +43 -0
- package/dist/aggregator.js +270 -0
- package/dist/analyze/analyzeGate.d.ts +44 -0
- package/dist/analyze/analyzeGate.js +114 -0
- package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
- package/dist/analyze/apiOverconsumptionDetector.js +75 -0
- package/dist/analyze/astDetectors.d.ts +38 -0
- package/dist/analyze/astDetectors.js +566 -0
- package/dist/analyze/buildResourceFindings.d.ts +34 -0
- package/dist/analyze/buildResourceFindings.js +86 -0
- package/dist/analyze/cli.d.ts +20 -0
- package/dist/analyze/cli.js +186 -0
- package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
- package/dist/analyze/cpuResourceDetectors.js +163 -0
- package/dist/analyze/heuristicScan.d.ts +61 -0
- package/dist/analyze/heuristicScan.js +91 -0
- package/dist/analyze/index.d.ts +15 -0
- package/dist/analyze/index.js +15 -0
- package/dist/analyze/loadTypescript.d.ts +8 -0
- package/dist/analyze/loadTypescript.js +23 -0
- package/dist/analyze/memoryIoDetectors.d.ts +15 -0
- package/dist/analyze/memoryIoDetectors.js +68 -0
- package/dist/analyze/preferAstDetectors.d.ts +18 -0
- package/dist/analyze/preferAstDetectors.js +91 -0
- package/dist/analyze/projectScan.d.ts +19 -0
- package/dist/analyze/projectScan.js +115 -0
- package/dist/analyze/resourceAdmission.d.ts +33 -0
- package/dist/analyze/resourceAdmission.js +91 -0
- package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
- package/dist/analyze/resourceFindingCandidate.js +75 -0
- package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
- package/dist/analyze/retryAmplificationDetector.js +55 -0
- package/dist/analyze/schemas/resource-finding.schema.json +352 -0
- package/dist/analyze/serviceDiscovery.d.ts +66 -0
- package/dist/analyze/serviceDiscovery.js +210 -0
- package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
- package/dist/analyze/unboundedParallelismDetector.js +57 -0
- package/dist/analyze/validateResourceFinding.d.ts +19 -0
- package/dist/analyze/validateResourceFinding.js +46 -0
- package/dist/bullmq.d.ts +33 -0
- package/dist/bullmq.js +106 -0
- package/dist/cli/helpText.d.ts +8 -0
- package/dist/cli/helpText.js +99 -0
- package/dist/cli/netgreener.d.ts +12 -0
- package/dist/cli/netgreener.js +65 -0
- package/dist/collectorIpc.d.ts +28 -0
- package/dist/collectorIpc.js +189 -0
- package/dist/collectorMetadata.d.ts +15 -0
- package/dist/collectorMetadata.js +33 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +97 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +1 -0
- package/dist/contract/observation-envelope.schema.json +331 -0
- package/dist/contract/observation-protobuf-view.schema.json +773 -0
- package/dist/contract/observation-v1.d.mts +42 -0
- package/dist/contract/observation-v1.mjs +555 -0
- package/dist/contract/observationIdentity.d.ts +11 -0
- package/dist/contract/observationIdentity.js +48 -0
- package/dist/contract/observationProjection.d.ts +3 -0
- package/dist/contract/observationProjection.js +29 -0
- package/dist/contract/validate.d.ts +20 -0
- package/dist/contract/validate.js +227 -0
- package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
- package/dist/dogfood/mp3WorkerGates.js +86 -0
- package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
- package/dist/dogfood/retainDryRunArtifact.js +103 -0
- package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
- package/dist/dogfood/tenantDogfoodGates.js +206 -0
- package/dist/exporter.d.ts +56 -0
- package/dist/exporter.js +304 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +118 -0
- package/dist/externalApiMeter.d.ts +72 -0
- package/dist/externalApiMeter.js +1167 -0
- package/dist/fastify.d.ts +53 -0
- package/dist/fastify.js +148 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +31 -0
- package/dist/n4/deploymentMatrix.d.ts +22 -0
- package/dist/n4/deploymentMatrix.js +57 -0
- package/dist/n4/missingnessInventory.d.ts +22 -0
- package/dist/n4/missingnessInventory.js +72 -0
- package/dist/nest.d.ts +42 -0
- package/dist/nest.js +87 -0
- package/dist/processResources.d.ts +28 -0
- package/dist/processResources.js +31 -0
- package/dist/processRuntime.d.ts +35 -0
- package/dist/processRuntime.js +96 -0
- package/dist/requestContext.d.ts +21 -0
- package/dist/requestContext.js +33 -0
- package/dist/runtime.d.ts +60 -0
- package/dist/runtime.js +289 -0
- package/dist/runtimeHealth.d.ts +35 -0
- package/dist/runtimeHealth.js +121 -0
- package/dist/serverlessHints.d.ts +26 -0
- package/dist/serverlessHints.js +52 -0
- package/dist/tenantContext.d.ts +78 -0
- package/dist/tenantContext.js +226 -0
- package/dist/tenantDefaults.d.ts +9 -0
- package/dist/tenantDefaults.js +9 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.js +2 -0
- package/dist/uploader.d.ts +23 -0
- package/dist/uploader.js +52 -0
- package/dist/windowId.d.ts +2 -0
- package/dist/windowId.js +7 -0
- package/examples/analyze-manifest.mjs +30 -0
- package/examples/bullmq-live-smoke.mjs +178 -0
- package/examples/bullmq-mp3-worker-gate.mjs +237 -0
- package/examples/express-dry-run.mjs +72 -0
- package/examples/process-mp3-restart-gate.mjs +135 -0
- package/examples/retain-dry-run.mjs +110 -0
- package/examples/tenant-dogfood.mjs +269 -0
- package/fixtures/analyze-sample/architectureNoise.ts +13 -0
- package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
- package/fixtures/analyze-sample/fanOutClient.ts +15 -0
- package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
- package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
- package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
- package/fixtures/analyze-sample/nestedLookup.ts +15 -0
- package/fixtures/analyze-sample/retryClient.ts +10 -0
- package/fixtures/analyze-sample/securitySmell.ts +8 -0
- package/fixtures/analyze-sample/server.ts +11 -0
- package/fixtures/analyze-sample/styleOnly.ts +7 -0
- package/fixtures/analyze-sample/worker.ts +4 -0
- package/fixtures/dual-view-projection.v1.json +53 -0
- package/fixtures/identity-preimages.v1.json +42 -0
- package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
- package/fixtures/projection-batch-document.v1.json +170 -0
- package/fixtures/projection-batch.v1.json +9 -0
- package/fixtures/python_reference_shapes.md +24 -0
- package/fixtures/session_metadata_runtime_v0.json +120 -0
- package/fixtures/session_metadata_with_tenant.json +85 -0
- package/package.json +137 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @param {unknown} document @returns {{accepted: boolean, errorCodes: string[]}} */
|
|
2
|
+
export function validateObservationEnvelope(document: unknown): {
|
|
3
|
+
accepted: boolean;
|
|
4
|
+
errorCodes: string[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Pair comparison only, with both complete documents validated first.
|
|
8
|
+
* Object order is ignored and array order preserved. Decoded number equality is
|
|
9
|
+
* provisional: this is not RFC 8785 or the still-open MP0 numeric/wire policy.
|
|
10
|
+
* @param {unknown} first
|
|
11
|
+
* @param {unknown} second
|
|
12
|
+
* @returns {{classification: 'duplicate'|'conflict'|'reject', errorCodes: string[]}}
|
|
13
|
+
*/
|
|
14
|
+
export function classifyObservationDelivery(first: unknown, second: unknown): {
|
|
15
|
+
classification: "duplicate" | "conflict" | "reject";
|
|
16
|
+
errorCodes: string[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Project semantic authoring envelope → Protobuf JSON view without mutating input.
|
|
20
|
+
* @param {unknown} observation
|
|
21
|
+
* @returns {{accepted: boolean, document: object|null, errorCodes: string[]}}
|
|
22
|
+
*/
|
|
23
|
+
export function projectSemanticEnvelopeToProtobufView(observation: unknown): {
|
|
24
|
+
accepted: boolean;
|
|
25
|
+
document: object | null;
|
|
26
|
+
errorCodes: string[];
|
|
27
|
+
};
|
|
28
|
+
/** @param {unknown} document @returns {{accepted: boolean, errorCodes: string[]}} */
|
|
29
|
+
export function validateObservationProtobufView(document: unknown): {
|
|
30
|
+
accepted: boolean;
|
|
31
|
+
errorCodes: string[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Additive dual-view gate: semantic envelope must validate and project to a valid protobuf view.
|
|
35
|
+
* Does not replace validateObservationEnvelope for TCK.
|
|
36
|
+
* @param {unknown} observation
|
|
37
|
+
* @returns {{accepted: boolean, errorCodes: string[]}}
|
|
38
|
+
*/
|
|
39
|
+
export function validateObservationDualView(observation: unknown): {
|
|
40
|
+
accepted: boolean;
|
|
41
|
+
errorCodes: string[];
|
|
42
|
+
};
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public producer-validation and pair-identity boundaries for the pinned MP0 draft.
|
|
3
|
+
* No collector, cloud upload, fixture oracle, or conformance result generation lives here.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
let shapeValidator
|
|
10
|
+
let protobufViewValidator
|
|
11
|
+
function validateShape(document) {
|
|
12
|
+
// Legacy runtime imports should not compile or load the draft validator eagerly.
|
|
13
|
+
if (!shapeValidator) {
|
|
14
|
+
const Ajv2020 = require('ajv/dist/2020.js').default
|
|
15
|
+
const addFormats = require('ajv-formats').default
|
|
16
|
+
const schema = JSON.parse(readFileSync(new URL('./observation-envelope.schema.json', import.meta.url), 'utf8'))
|
|
17
|
+
// Disable strict schema-authoring warnings, not producer/format validation.
|
|
18
|
+
const ajv = new Ajv2020({ strict: false, strictNumbers: true, allErrors: false, ownProperties: true })
|
|
19
|
+
addFormats(ajv, ['date-time'])
|
|
20
|
+
shapeValidator = ajv.compile(schema)
|
|
21
|
+
}
|
|
22
|
+
return shapeValidator(document)
|
|
23
|
+
}
|
|
24
|
+
function validateProtobufViewShape(document) {
|
|
25
|
+
if (!protobufViewValidator) {
|
|
26
|
+
const Ajv2020 = require('ajv/dist/2020.js').default
|
|
27
|
+
const addFormats = require('ajv-formats').default
|
|
28
|
+
const schema = JSON.parse(readFileSync(new URL('./observation-protobuf-view.schema.json', import.meta.url), 'utf8'))
|
|
29
|
+
const ajv = new Ajv2020({ strict: false, strictNumbers: true, allErrors: false, ownProperties: true })
|
|
30
|
+
addFormats(ajv, ['date-time'])
|
|
31
|
+
protobufViewValidator = ajv.compile(schema)
|
|
32
|
+
}
|
|
33
|
+
return protobufViewValidator(document)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PROTOCOL_MAJOR = 1
|
|
37
|
+
const PROTOCOL_MINOR = 0
|
|
38
|
+
/** Mirrors observation/v1/json-semantic-mapping.v1.json (dual-view migration). */
|
|
39
|
+
const ENUM_MAPS = {
|
|
40
|
+
deployment_mode: {
|
|
41
|
+
vm: 'DEPLOYMENT_MODE_VM', container: 'DEPLOYMENT_MODE_CONTAINER',
|
|
42
|
+
kubernetes: 'DEPLOYMENT_MODE_KUBERNETES', ci_job: 'DEPLOYMENT_MODE_CI_JOB',
|
|
43
|
+
serverless_thin: 'DEPLOYMENT_MODE_SERVERLESS_THIN', local: 'DEPLOYMENT_MODE_LOCAL',
|
|
44
|
+
unknown: 'DEPLOYMENT_MODE_UNKNOWN',
|
|
45
|
+
},
|
|
46
|
+
content_capture: { none: 'CONTENT_CAPTURE_NONE' },
|
|
47
|
+
dimension_policy: { allowlist: 'DIMENSION_POLICY_ALLOWLIST' },
|
|
48
|
+
route_policy: { template_or_unknown: 'ROUTE_POLICY_TEMPLATE_OR_UNKNOWN' },
|
|
49
|
+
window_state: { open: 'WINDOW_STATE_OPEN', closed: 'WINDOW_STATE_CLOSED', thin: 'WINDOW_STATE_THIN' },
|
|
50
|
+
clock_source: {
|
|
51
|
+
runtime_monotonic: 'CLOCK_SOURCE_RUNTIME_MONOTONIC',
|
|
52
|
+
process_monotonic: 'CLOCK_SOURCE_PROCESS_MONOTONIC',
|
|
53
|
+
unavailable: 'CLOCK_SOURCE_UNAVAILABLE',
|
|
54
|
+
},
|
|
55
|
+
operation_kind: {
|
|
56
|
+
'http.server': 'OPERATION_KIND_HTTP_SERVER', task: 'OPERATION_KIND_TASK',
|
|
57
|
+
process: 'OPERATION_KIND_PROCESS', script: 'OPERATION_KIND_SCRIPT',
|
|
58
|
+
cron: 'OPERATION_KIND_CRON', background: 'OPERATION_KIND_BACKGROUND',
|
|
59
|
+
unattributed: 'OPERATION_KIND_UNATTRIBUTED',
|
|
60
|
+
},
|
|
61
|
+
operation_unit: {
|
|
62
|
+
http_route: 'OPERATION_UNIT_HTTP_ROUTE', task: 'OPERATION_UNIT_TASK',
|
|
63
|
+
process: 'OPERATION_UNIT_PROCESS', script: 'OPERATION_UNIT_SCRIPT',
|
|
64
|
+
cron: 'OPERATION_UNIT_CRON', background: 'OPERATION_UNIT_BACKGROUND',
|
|
65
|
+
unattributed: 'OPERATION_UNIT_UNATTRIBUTED',
|
|
66
|
+
},
|
|
67
|
+
identity_source: {
|
|
68
|
+
framework_template: 'IDENTITY_SOURCE_FRAMEWORK_TEMPLATE',
|
|
69
|
+
declared: 'IDENTITY_SOURCE_DECLARED', generated: 'IDENTITY_SOURCE_GENERATED',
|
|
70
|
+
unknown_bucket: 'IDENTITY_SOURCE_UNKNOWN_BUCKET',
|
|
71
|
+
},
|
|
72
|
+
operation_phase: {
|
|
73
|
+
start: 'OPERATION_PHASE_START', finish: 'OPERATION_PHASE_FINISH',
|
|
74
|
+
heartbeat: 'OPERATION_PHASE_HEARTBEAT',
|
|
75
|
+
},
|
|
76
|
+
outcome: {
|
|
77
|
+
ok: 'OUTCOME_OK', error: 'OUTCOME_ERROR', cancelled: 'OUTCOME_CANCELLED',
|
|
78
|
+
unknown: 'OUTCOME_UNKNOWN',
|
|
79
|
+
},
|
|
80
|
+
usage_source: {
|
|
81
|
+
provider_response: 'USAGE_SOURCE_PROVIDER_RESPONSE',
|
|
82
|
+
documented_header: 'USAGE_SOURCE_DOCUMENTED_HEADER',
|
|
83
|
+
sdk_callback: 'USAGE_SOURCE_SDK_CALLBACK', declared: 'USAGE_SOURCE_DECLARED',
|
|
84
|
+
},
|
|
85
|
+
resource_scope: {
|
|
86
|
+
process: 'RESOURCE_SCOPE_PROCESS', process_tree: 'RESOURCE_SCOPE_PROCESS_TREE',
|
|
87
|
+
container: 'RESOURCE_SCOPE_CONTAINER', cgroup: 'RESOURCE_SCOPE_CGROUP',
|
|
88
|
+
host: 'RESOURCE_SCOPE_HOST', device: 'RESOURCE_SCOPE_DEVICE',
|
|
89
|
+
},
|
|
90
|
+
sample_kind: {
|
|
91
|
+
start: 'SAMPLE_KIND_START', periodic: 'SAMPLE_KIND_PERIODIC',
|
|
92
|
+
end: 'SAMPLE_KIND_END', snapshot: 'SAMPLE_KIND_SNAPSHOT',
|
|
93
|
+
},
|
|
94
|
+
measurement_name: {
|
|
95
|
+
'process.cpu.time': 'MEASUREMENT_NAME_PROCESS_CPU_TIME',
|
|
96
|
+
'process.memory.rss': 'MEASUREMENT_NAME_PROCESS_MEMORY_RSS',
|
|
97
|
+
'system.energy': 'MEASUREMENT_NAME_SYSTEM_ENERGY',
|
|
98
|
+
'gpu.energy': 'MEASUREMENT_NAME_GPU_ENERGY',
|
|
99
|
+
'gpu.utilization': 'MEASUREMENT_NAME_GPU_UTILIZATION',
|
|
100
|
+
},
|
|
101
|
+
measurement_unit: {
|
|
102
|
+
ns: 'MEASUREMENT_UNIT_NS', By: 'MEASUREMENT_UNIT_BY',
|
|
103
|
+
J: 'MEASUREMENT_UNIT_J', '1': 'MEASUREMENT_UNIT_RATIO',
|
|
104
|
+
},
|
|
105
|
+
temporality: {
|
|
106
|
+
cumulative: 'TEMPORALITY_CUMULATIVE', delta: 'TEMPORALITY_DELTA', gauge: 'TEMPORALITY_GAUGE',
|
|
107
|
+
},
|
|
108
|
+
health_component: {
|
|
109
|
+
adapter: 'HEALTH_COMPONENT_ADAPTER', exporter: 'HEALTH_COMPONENT_EXPORTER',
|
|
110
|
+
sampler: 'HEALTH_COMPONENT_SAMPLER', collector: 'HEALTH_COMPONENT_COLLECTOR',
|
|
111
|
+
},
|
|
112
|
+
health_status: {
|
|
113
|
+
ok: 'HEALTH_STATUS_OK', degraded: 'HEALTH_STATUS_DEGRADED',
|
|
114
|
+
error: 'HEALTH_STATUS_ERROR', disabled: 'HEALTH_STATUS_DISABLED',
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const contentFields = new Set(['authorization', 'body', 'completion', 'cookie', 'database_statement', 'error_text', 'headers', 'prompt', 'request_body', 'response_body', 'secret'])
|
|
119
|
+
const derivedFields = new Set(['carbon', 'carbon_factor', 'carbon_g', 'co2e', 'cost', 'cost_usd', 'currency', 'energy_estimate', 'energy_kwh', 'estimated_energy', 'estimated_energy_j', 'price', 'pricing'])
|
|
120
|
+
const knownCapabilities = new Set(['CAP-R1', 'CAP-R2', 'CAP-R3', 'CAP-R4', 'CAP-R5', 'CAP-R6', 'CAP-A1', 'CAP-A2', 'CAP-A3', 'CAP-O1', 'CAP-O2', 'CAP-P1', 'CAP-P2', 'CAP-Q1'])
|
|
121
|
+
const operationKinds = new Set(['http.server', 'task', 'process', 'script', 'cron', 'background', 'unattributed'])
|
|
122
|
+
const recordCapabilities = new Map([['external_call', 'CAP-R3'], ['resource_sample', 'CAP-R4'], ['adapter_health', 'CAP-R5']])
|
|
123
|
+
const measurementShapes = new Map([
|
|
124
|
+
['process.cpu.time', ['ns', ['cumulative', 'delta']]],
|
|
125
|
+
['process.memory.rss', ['By', ['gauge']]],
|
|
126
|
+
['system.energy', ['J', ['cumulative', 'delta']]],
|
|
127
|
+
['gpu.energy', ['J', ['cumulative', 'delta']]],
|
|
128
|
+
['gpu.utilization', ['1', ['gauge']]],
|
|
129
|
+
])
|
|
130
|
+
const uint64Max = (1n << 64n) - 1n
|
|
131
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
132
|
+
const array = (value) => Array.isArray(value) ? value : []
|
|
133
|
+
const has = (value, key) => Object.hasOwn(value, key)
|
|
134
|
+
const integer = (value) => {
|
|
135
|
+
if (typeof value === 'string' && /^-?[0-9]+$/.test(value)) return BigInt(value)
|
|
136
|
+
if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)) return BigInt(value)
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkRange(value, codes) {
|
|
141
|
+
const parsed = integer(value)
|
|
142
|
+
if (parsed !== null && (parsed < 0n || parsed > uint64Max)) codes.add('UINT64_OUT_OF_RANGE')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function repeated(values) {
|
|
146
|
+
return new Set(values).size !== values.length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function strictTimestamp(value) {
|
|
150
|
+
// Match the draft Python checker on leap seconds/year zero, while rejecting its
|
|
151
|
+
// trailing-newline quirk. Calendar validity remains the schema format check.
|
|
152
|
+
return typeof value === 'string' && !/\s/.test(value) &&
|
|
153
|
+
/^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$/.test(value)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Reject native values/cycles before handing an arbitrary caller's input to Ajv.
|
|
157
|
+
// Error codes never contain customer values or validation-library error messages.
|
|
158
|
+
function inspectJson(value, codes, ancestors = new Set(), depth = 0) {
|
|
159
|
+
if (depth > 64) return false
|
|
160
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
|
161
|
+
if (typeof value === 'number') {
|
|
162
|
+
if (!Number.isFinite(value)) { codes.add('NONFINITE_NUMBER'); return false }
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
if (!object(value) && !Array.isArray(value)) return false
|
|
166
|
+
if (ancestors.has(value)) return false
|
|
167
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false
|
|
168
|
+
ancestors.add(value)
|
|
169
|
+
let valid = true
|
|
170
|
+
for (const [key, child] of Object.entries(value)) {
|
|
171
|
+
const lower = key.toLowerCase()
|
|
172
|
+
if (contentFields.has(lower)) codes.add('FORBIDDEN_CONTENT_FIELD')
|
|
173
|
+
if (derivedFields.has(lower)) codes.add('FORBIDDEN_DERIVED_FIELD')
|
|
174
|
+
valid = inspectJson(child, codes, ancestors, depth + 1) && valid
|
|
175
|
+
}
|
|
176
|
+
ancestors.delete(value)
|
|
177
|
+
return valid
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function checkOperation(operation, codes) {
|
|
181
|
+
if (!object(operation)) return
|
|
182
|
+
const expectedUnit = operation.kind === 'http.server' ? 'http_route' : operation.kind
|
|
183
|
+
if (operationKinds.has(operation.kind) && operation.unit_type !== expectedUnit) codes.add('OPERATION_KIND_UNIT_MISMATCH')
|
|
184
|
+
if (operation.kind !== 'http.server') return
|
|
185
|
+
const source = operation.identity_source
|
|
186
|
+
const route = operation.route_template
|
|
187
|
+
const name = operation.name
|
|
188
|
+
const allowedSource = ['framework_template', 'declared', 'generated', 'unknown_bucket'].includes(source)
|
|
189
|
+
const rawRoute = typeof route !== 'string' || /[?#]/.test(route) ||
|
|
190
|
+
(source === 'generated' && /(?:^|\/)(?:[0-9]{4,}|[0-9a-f]{8}-[0-9a-f-]{27,})(?:\/|$)/i.test(route))
|
|
191
|
+
if (!allowedSource || rawRoute || (typeof name === 'string' && /[?#]/.test(name)) ||
|
|
192
|
+
(source === 'unknown_bucket' && route !== '__unknown__') ||
|
|
193
|
+
(route === '__unknown__' && source !== 'unknown_bucket')) codes.add('RAW_ROUTE_IDENTITY_FORBIDDEN')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkFacts(event, codes) {
|
|
197
|
+
const facts = event.facts
|
|
198
|
+
if (!object(facts)) return
|
|
199
|
+
if (event.record_type === 'operation_summary') {
|
|
200
|
+
if (Number.isInteger(facts.calls) && Number.isInteger(facts.errors) && facts.errors > facts.calls) codes.add('INVALID_COUNTER_RELATIONSHIP')
|
|
201
|
+
const sum = integer(facts.duration_ns_sum)
|
|
202
|
+
const max = integer(facts.duration_ns_max)
|
|
203
|
+
if (sum !== null && max !== null && max > sum) codes.add('INVALID_DURATION_RELATIONSHIP')
|
|
204
|
+
checkRange(facts.duration_ns_sum, codes)
|
|
205
|
+
checkRange(facts.duration_ns_max, codes)
|
|
206
|
+
} else if (event.record_type === 'operation_event') {
|
|
207
|
+
if (typeof event.execution_id !== 'string') codes.add('LIFECYCLE_EXECUTION_ID_REQUIRED')
|
|
208
|
+
if ((facts.phase === 'start' && (facts.outcome !== 'unknown' || has(facts, 'duration_ns'))) ||
|
|
209
|
+
(facts.phase === 'finish' && !has(facts, 'duration_ns'))) codes.add('LIFECYCLE_PHASE_FACT_MISMATCH')
|
|
210
|
+
if (has(facts, 'duration_ns')) checkRange(facts.duration_ns, codes)
|
|
211
|
+
} else if (event.record_type === 'external_call') {
|
|
212
|
+
if (Number.isInteger(facts.attempts) && Number.isInteger(facts.errors) && facts.errors > facts.attempts) codes.add('INVALID_COUNTER_RELATIONSHIP')
|
|
213
|
+
checkRange(facts.duration_ns, codes)
|
|
214
|
+
const usage = array(facts.usage).filter(object)
|
|
215
|
+
if (repeated(usage.map((entry) => entry.unit))) codes.add('DUPLICATE_USAGE_UNIT')
|
|
216
|
+
for (const entry of usage) checkRange(entry.value, codes)
|
|
217
|
+
} else if (event.record_type === 'resource_sample') {
|
|
218
|
+
const measurements = array(facts.measurements).filter(object)
|
|
219
|
+
if (repeated(measurements.map((entry) => entry.name))) codes.add('DUPLICATE_MEASUREMENT')
|
|
220
|
+
for (const entry of measurements) {
|
|
221
|
+
const shape = measurementShapes.get(entry.name)
|
|
222
|
+
if (shape && (entry.unit !== shape[0] || !shape[1].includes(entry.temporality))) codes.add('INVALID_MEASUREMENT_SHAPE')
|
|
223
|
+
if (entry.name === 'process.cpu.time') {
|
|
224
|
+
checkRange(entry.value, codes)
|
|
225
|
+
if (/request_duration|wall|elapsed|proxy/i.test(String(entry.source ?? ''))) codes.add('CPU_SOURCE_WALL_DURATION')
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {unknown} document @returns {{accepted: boolean, errorCodes: string[]}} */
|
|
232
|
+
export function validateObservationEnvelope(document) {
|
|
233
|
+
const codes = new Set()
|
|
234
|
+
try {
|
|
235
|
+
if (!inspectJson(document, codes)) {
|
|
236
|
+
codes.add('SCHEMA_VALIDATION_ERROR')
|
|
237
|
+
return { accepted: false, errorCodes: [...codes].sort() }
|
|
238
|
+
}
|
|
239
|
+
if (!validateShape(document)) codes.add('SCHEMA_VALIDATION_ERROR')
|
|
240
|
+
if (!object(document)) return { accepted: false, errorCodes: [...codes].sort() }
|
|
241
|
+
if (!strictTimestamp(document.emitted_at)) codes.add('SCHEMA_VALIDATION_ERROR')
|
|
242
|
+
if (object(document.window)) {
|
|
243
|
+
if (!strictTimestamp(document.window.start_time) ||
|
|
244
|
+
(document.window.end_time != null && !strictTimestamp(document.window.end_time))) codes.add('SCHEMA_VALIDATION_ERROR')
|
|
245
|
+
}
|
|
246
|
+
checkRange(document.batch_sequence, codes)
|
|
247
|
+
const capabilities = new Set(array(document.source?.capability_ids).filter((id) => typeof id === 'string'))
|
|
248
|
+
if ([...capabilities].some((id) => !knownCapabilities.has(id))) codes.add('UNKNOWN_CAPABILITY_ID')
|
|
249
|
+
const events = array(document.events).filter(object)
|
|
250
|
+
const ids = events.map((event) => event.event_id).filter((id) => typeof id === 'string')
|
|
251
|
+
const idSet = new Set(ids)
|
|
252
|
+
const sequences = events.map((event) => integer(event.sequence)).filter((value) => value !== null)
|
|
253
|
+
if (repeated(ids)) codes.add('DUPLICATE_EVENT_ID')
|
|
254
|
+
if (repeated(sequences)) codes.add('DUPLICATE_SEQUENCE')
|
|
255
|
+
if (sequences.some((value, index) => index > 0 && value <= sequences[index - 1])) codes.add('INVALID_SEQUENCE_ORDER')
|
|
256
|
+
for (const event of events) {
|
|
257
|
+
for (const field of ['sequence', 'wall_time_unix_nano', 'monotonic_time_ns']) checkRange(event[field], codes)
|
|
258
|
+
if (typeof event.traceparent === 'string') {
|
|
259
|
+
const parts = event.traceparent.split('-')
|
|
260
|
+
if (parts.length !== 4 || parts[0] === 'ff' || parts[1] === '0'.repeat(32) || parts[2] === '0'.repeat(16)) codes.add('INVALID_TRACEPARENT')
|
|
261
|
+
}
|
|
262
|
+
if (event.parent_operation_event_id != null && !idSet.has(event.parent_operation_event_id)) codes.add('UNKNOWN_PARENT_EVENT')
|
|
263
|
+
checkOperation(event.operation, codes)
|
|
264
|
+
checkFacts(event, codes)
|
|
265
|
+
if (object(event.facts)) {
|
|
266
|
+
const capability = ['operation_event', 'operation_summary'].includes(event.record_type)
|
|
267
|
+
? (event.operation?.kind === 'http.server' ? 'CAP-R1' : 'CAP-R2')
|
|
268
|
+
: recordCapabilities.get(event.record_type)
|
|
269
|
+
if (capability && !capabilities.has(capability)) codes.add('SOURCE_CAPABILITY_MISMATCH')
|
|
270
|
+
}
|
|
271
|
+
const windowId = object(document.window) ? document.window.runtime_window_id : null
|
|
272
|
+
if (windowId) {
|
|
273
|
+
if (event.runtime_window_id == null) codes.add('WINDOW_ID_MISSING')
|
|
274
|
+
else if (event.runtime_window_id !== windowId) codes.add('WINDOW_ID_MISMATCH')
|
|
275
|
+
}
|
|
276
|
+
if (event.clock_source === 'unavailable' && event.monotonic_time_ns !== '0') codes.add('CLOCK_SOURCE_MISMATCH')
|
|
277
|
+
}
|
|
278
|
+
const window = document.window
|
|
279
|
+
if (object(window) && ((window.state === 'open' && window.end_time != null) ||
|
|
280
|
+
(window.state === 'closed' && window.end_time == null))) codes.add('WINDOW_STATE_TIME_MISMATCH')
|
|
281
|
+
} catch {
|
|
282
|
+
codes.add('SCHEMA_VALIDATION_ERROR')
|
|
283
|
+
}
|
|
284
|
+
return { accepted: codes.size === 0, errorCodes: [...codes].sort() }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function semanticEqual(left, right) {
|
|
288
|
+
if (left === right) return true
|
|
289
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
290
|
+
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
|
|
291
|
+
left.every((value, index) => semanticEqual(value, right[index]))
|
|
292
|
+
}
|
|
293
|
+
if (!object(left) || !object(right)) return false
|
|
294
|
+
const keys = Object.keys(left)
|
|
295
|
+
return keys.length === Object.keys(right).length && keys.every((key) => has(right, key) && semanticEqual(left[key], right[key]))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Pair comparison only, with both complete documents validated first.
|
|
300
|
+
* Object order is ignored and array order preserved. Decoded number equality is
|
|
301
|
+
* provisional: this is not RFC 8785 or the still-open MP0 numeric/wire policy.
|
|
302
|
+
* @param {unknown} first
|
|
303
|
+
* @param {unknown} second
|
|
304
|
+
* @returns {{classification: 'duplicate'|'conflict'|'reject', errorCodes: string[]}}
|
|
305
|
+
*/
|
|
306
|
+
export function classifyObservationDelivery(first, second) {
|
|
307
|
+
const errorCodes = [...new Set([
|
|
308
|
+
...validateObservationEnvelope(first).errorCodes,
|
|
309
|
+
...validateObservationEnvelope(second).errorCodes,
|
|
310
|
+
])].sort()
|
|
311
|
+
if (errorCodes.length) return { classification: 'reject', errorCodes }
|
|
312
|
+
if (first.batch_id !== second.batch_id) return { classification: 'conflict', errorCodes: ['PAIR_BATCH_ID_MISMATCH'] }
|
|
313
|
+
return semanticEqual(first, second)
|
|
314
|
+
? { classification: 'duplicate', errorCodes: [] }
|
|
315
|
+
: { classification: 'conflict', errorCodes: ['ID_PAYLOAD_CONFLICT'] }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mapEnum(table, value, codes) {
|
|
319
|
+
const mapped = typeof value === 'string' ? ENUM_MAPS[table][value] : undefined
|
|
320
|
+
if (mapped === undefined) codes.add('PROJECTION_UNMAPPED_ENUM')
|
|
321
|
+
return mapped
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function asUint64String(value, codes, required = false) {
|
|
325
|
+
if (value == null) {
|
|
326
|
+
if (required) codes.add('PROJECTION_SHAPE_ERROR')
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
if (typeof value === 'boolean') {
|
|
330
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
331
|
+
return undefined
|
|
332
|
+
}
|
|
333
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return String(value)
|
|
334
|
+
if (typeof value === 'string' && /^[0-9]+$/.test(value)) return value
|
|
335
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
336
|
+
return undefined
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function asCoverageString(value, codes) {
|
|
340
|
+
if (typeof value === 'boolean') {
|
|
341
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1) {
|
|
345
|
+
if (value === 0 || value === 1) return String(value)
|
|
346
|
+
const text = value.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')
|
|
347
|
+
if (!/^0\.[0-9]{0,11}[1-9]$/.test(text)) {
|
|
348
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
return text
|
|
352
|
+
}
|
|
353
|
+
if (typeof value === 'string' && /^(0|1|0\.[0-9]{0,11}[1-9])$/.test(value)) return value
|
|
354
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
355
|
+
return undefined
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function projectOperation(operation, codes) {
|
|
359
|
+
const projected = {
|
|
360
|
+
kind: mapEnum('operation_kind', operation.kind, codes),
|
|
361
|
+
unit_type: mapEnum('operation_unit', operation.unit_type, codes),
|
|
362
|
+
name: operation.name,
|
|
363
|
+
identity_source: mapEnum('identity_source', operation.identity_source, codes),
|
|
364
|
+
}
|
|
365
|
+
if (has(operation, 'http_method')) projected.http_method = operation.http_method
|
|
366
|
+
if (has(operation, 'route_template')) projected.route_template = operation.route_template
|
|
367
|
+
return projected
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function projectEvent(event, codes) {
|
|
371
|
+
const projected = {
|
|
372
|
+
event_id: event.event_id,
|
|
373
|
+
sequence: asUint64String(event.sequence, codes, true),
|
|
374
|
+
wall_time_unix_nano: asUint64String(event.wall_time_unix_nano, codes, true),
|
|
375
|
+
clock_source: mapEnum('clock_source', event.clock_source, codes),
|
|
376
|
+
}
|
|
377
|
+
if (has(event, 'monotonic_time_ns')) projected.monotonic_time_ns = asUint64String(event.monotonic_time_ns, codes)
|
|
378
|
+
for (const key of ['execution_id', 'runtime_window_id', 'traceparent', 'clock_epoch_id']) {
|
|
379
|
+
if (event[key] != null) projected[key] = event[key]
|
|
380
|
+
}
|
|
381
|
+
const facts = object(event.facts) ? event.facts : {}
|
|
382
|
+
const parent = event.parent_operation_event_id
|
|
383
|
+
if (event.record_type === 'operation_event') {
|
|
384
|
+
const body = {
|
|
385
|
+
operation: projectOperation(object(event.operation) ? event.operation : {}, codes),
|
|
386
|
+
phase: mapEnum('operation_phase', facts.phase, codes),
|
|
387
|
+
}
|
|
388
|
+
if (has(facts, 'outcome')) body.outcome = mapEnum('outcome', facts.outcome, codes)
|
|
389
|
+
if (has(facts, 'duration_ns')) body.duration_ns = asUint64String(facts.duration_ns, codes)
|
|
390
|
+
if (facts.status_code != null) body.status_code = facts.status_code
|
|
391
|
+
projected.operation_event = body
|
|
392
|
+
} else if (event.record_type === 'operation_summary') {
|
|
393
|
+
projected.operation_summary = {
|
|
394
|
+
operation: projectOperation(object(event.operation) ? event.operation : {}, codes),
|
|
395
|
+
calls: asUint64String(facts.calls, codes, true),
|
|
396
|
+
errors: asUint64String(facts.errors, codes, true),
|
|
397
|
+
duration_ns_sum: asUint64String(facts.duration_ns_sum, codes, true),
|
|
398
|
+
duration_ns_max: asUint64String(facts.duration_ns_max, codes, true),
|
|
399
|
+
...(facts.concurrency_max != null ? { concurrency_max: facts.concurrency_max } : {}),
|
|
400
|
+
}
|
|
401
|
+
} else if (event.record_type === 'external_call') {
|
|
402
|
+
const body = {
|
|
403
|
+
service: facts.service,
|
|
404
|
+
operation: facts.operation,
|
|
405
|
+
attempts: asUint64String(facts.attempts, codes, true),
|
|
406
|
+
errors: asUint64String(facts.errors, codes, true),
|
|
407
|
+
duration_ns: asUint64String(facts.duration_ns, codes, true),
|
|
408
|
+
}
|
|
409
|
+
if (parent != null) body.parent_operation_event_id = parent
|
|
410
|
+
for (const key of ['provider', 'model', 'destination_domain']) {
|
|
411
|
+
if (facts[key] != null) body[key] = facts[key]
|
|
412
|
+
}
|
|
413
|
+
if (has(facts, 'usage')) {
|
|
414
|
+
body.usage = array(facts.usage).filter(object).map((entry) => ({
|
|
415
|
+
unit: entry.unit,
|
|
416
|
+
value: asUint64String(entry.value, codes, true),
|
|
417
|
+
source: mapEnum('usage_source', entry.source, codes),
|
|
418
|
+
}))
|
|
419
|
+
}
|
|
420
|
+
projected.external_call = body
|
|
421
|
+
} else if (event.record_type === 'resource_sample') {
|
|
422
|
+
const measurements = array(facts.measurements).filter(object).map((entry) => {
|
|
423
|
+
const measurement = {
|
|
424
|
+
name: mapEnum('measurement_name', entry.name, codes),
|
|
425
|
+
value: typeof entry.value === 'string' ? entry.value
|
|
426
|
+
: (typeof entry.value === 'number' && Number.isFinite(entry.value) ? String(entry.value) : undefined),
|
|
427
|
+
unit: mapEnum('measurement_unit', entry.unit, codes),
|
|
428
|
+
temporality: mapEnum('temporality', entry.temporality, codes),
|
|
429
|
+
source: entry.source,
|
|
430
|
+
coverage: asCoverageString(entry.coverage, codes),
|
|
431
|
+
}
|
|
432
|
+
if (measurement.value === undefined) codes.add('PROJECTION_SHAPE_ERROR')
|
|
433
|
+
if (entry.counter_epoch_id != null) measurement.counter_epoch_id = entry.counter_epoch_id
|
|
434
|
+
return measurement
|
|
435
|
+
})
|
|
436
|
+
const body = {
|
|
437
|
+
scope: mapEnum('resource_scope', facts.scope, codes),
|
|
438
|
+
sample_kind: mapEnum('sample_kind', facts.sample_kind, codes),
|
|
439
|
+
subject_id: facts.subject_id,
|
|
440
|
+
measurements,
|
|
441
|
+
}
|
|
442
|
+
if (parent != null) body.parent_operation_event_id = parent
|
|
443
|
+
projected.resource_sample = body
|
|
444
|
+
} else if (event.record_type === 'adapter_health') {
|
|
445
|
+
const countersIn = object(facts.counters) ? facts.counters : {}
|
|
446
|
+
const counters = Object.fromEntries(
|
|
447
|
+
Object.keys(countersIn).map((key) => [key, asUint64String(countersIn[key], codes, true)]),
|
|
448
|
+
)
|
|
449
|
+
projected.adapter_health = {
|
|
450
|
+
component: mapEnum('health_component', facts.component, codes),
|
|
451
|
+
status: mapEnum('health_status', facts.status, codes),
|
|
452
|
+
reason_codes: array(facts.reason_codes),
|
|
453
|
+
counters,
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
codes.add('PROJECTION_SHAPE_ERROR')
|
|
457
|
+
}
|
|
458
|
+
return projected
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Project semantic authoring envelope → Protobuf JSON view without mutating input.
|
|
463
|
+
* @param {unknown} observation
|
|
464
|
+
* @returns {{accepted: boolean, document: object|null, errorCodes: string[]}}
|
|
465
|
+
*/
|
|
466
|
+
export function projectSemanticEnvelopeToProtobufView(observation) {
|
|
467
|
+
const codes = new Set()
|
|
468
|
+
if (!object(observation)) {
|
|
469
|
+
return { accepted: false, document: null, errorCodes: ['PROJECTION_SHAPE_ERROR'] }
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const source = object(observation.source) ? observation.source : {}
|
|
473
|
+
const routing = object(observation.routing) ? observation.routing : {}
|
|
474
|
+
const privacy = object(observation.privacy) ? observation.privacy : {}
|
|
475
|
+
const document = {
|
|
476
|
+
protocol_major: PROTOCOL_MAJOR,
|
|
477
|
+
protocol_minor: PROTOCOL_MINOR,
|
|
478
|
+
batch_id: observation.batch_id,
|
|
479
|
+
batch_sequence: asUint64String(observation.batch_sequence, codes, true),
|
|
480
|
+
emitted_at: observation.emitted_at,
|
|
481
|
+
source: {
|
|
482
|
+
language: source.language,
|
|
483
|
+
runtime_name: source.runtime_name,
|
|
484
|
+
runtime_version: source.runtime_version,
|
|
485
|
+
adapter_name: source.adapter_name,
|
|
486
|
+
adapter_version: source.adapter_version,
|
|
487
|
+
runtime_instance_id: source.runtime_instance_id,
|
|
488
|
+
capability_ids: array(source.capability_ids).filter((id) => typeof id === 'string'),
|
|
489
|
+
deployment_mode: mapEnum('deployment_mode', source.deployment_mode, codes),
|
|
490
|
+
...(source.framework != null ? { framework: source.framework } : {}),
|
|
491
|
+
},
|
|
492
|
+
routing: {
|
|
493
|
+
project_id: asUint64String(routing.project_id, codes, true),
|
|
494
|
+
workload_id: routing.workload_id,
|
|
495
|
+
service_name: routing.service_name,
|
|
496
|
+
...(routing.organization_id != null
|
|
497
|
+
? { organization_id: asUint64String(routing.organization_id, codes, true) }
|
|
498
|
+
: {}),
|
|
499
|
+
...(routing.environment != null ? { environment: routing.environment } : {}),
|
|
500
|
+
...(routing.release_tag != null ? { release_tag: routing.release_tag } : {}),
|
|
501
|
+
},
|
|
502
|
+
privacy: {
|
|
503
|
+
content_capture: mapEnum('content_capture', privacy.content_capture, codes),
|
|
504
|
+
dimension_policy: mapEnum('dimension_policy', privacy.dimension_policy, codes),
|
|
505
|
+
route_policy: mapEnum('route_policy', privacy.route_policy, codes),
|
|
506
|
+
dropped_dimension_count: asUint64String(privacy.dropped_dimension_count, codes, true),
|
|
507
|
+
},
|
|
508
|
+
events: array(observation.events).filter(object).map((event) => projectEvent(event, codes)),
|
|
509
|
+
}
|
|
510
|
+
if (object(observation.window)) {
|
|
511
|
+
const window = observation.window
|
|
512
|
+
document.window = {
|
|
513
|
+
state: mapEnum('window_state', window.state, codes),
|
|
514
|
+
start_time: window.start_time,
|
|
515
|
+
...(window.runtime_window_id != null ? { runtime_window_id: window.runtime_window_id } : {}),
|
|
516
|
+
...(window.end_time != null ? { end_time: window.end_time } : {}),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (codes.size) return { accepted: false, document: null, errorCodes: [...codes].sort() }
|
|
520
|
+
return { accepted: true, document, errorCodes: [] }
|
|
521
|
+
} catch {
|
|
522
|
+
return { accepted: false, document: null, errorCodes: ['PROJECTION_SHAPE_ERROR'] }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** @param {unknown} document @returns {{accepted: boolean, errorCodes: string[]}} */
|
|
527
|
+
export function validateObservationProtobufView(document) {
|
|
528
|
+
const codes = new Set()
|
|
529
|
+
try {
|
|
530
|
+
if (!inspectJson(document, codes)) {
|
|
531
|
+
codes.add('SCHEMA_VALIDATION_ERROR')
|
|
532
|
+
return { accepted: false, errorCodes: [...codes].sort() }
|
|
533
|
+
}
|
|
534
|
+
if (!validateProtobufViewShape(document)) codes.add('SCHEMA_VALIDATION_ERROR')
|
|
535
|
+
} catch {
|
|
536
|
+
codes.add('SCHEMA_VALIDATION_ERROR')
|
|
537
|
+
}
|
|
538
|
+
return { accepted: codes.size === 0, errorCodes: [...codes].sort() }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Additive dual-view gate: semantic envelope must validate and project to a valid protobuf view.
|
|
543
|
+
* Does not replace validateObservationEnvelope for TCK.
|
|
544
|
+
* @param {unknown} observation
|
|
545
|
+
* @returns {{accepted: boolean, errorCodes: string[]}}
|
|
546
|
+
*/
|
|
547
|
+
export function validateObservationDualView(observation) {
|
|
548
|
+
const envelope = validateObservationEnvelope(observation)
|
|
549
|
+
if (!envelope.accepted) return envelope
|
|
550
|
+
const projected = projectSemanticEnvelopeToProtobufView(observation)
|
|
551
|
+
if (!projected.accepted || !projected.document) {
|
|
552
|
+
return { accepted: false, errorCodes: projected.errorCodes.length ? projected.errorCodes : ['PROJECTION_SHAPE_ERROR'] }
|
|
553
|
+
}
|
|
554
|
+
return validateObservationProtobufView(projected.document)
|
|
555
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const DOMAINS: {
|
|
2
|
+
readonly runtime_instance: "netgreener.observation.v1/runtime-instance";
|
|
3
|
+
readonly event: "netgreener.observation.v1/event";
|
|
4
|
+
readonly batch: "netgreener.observation.v1/batch";
|
|
5
|
+
readonly runtime_window: "netgreener.observation.v1/runtime-window";
|
|
6
|
+
};
|
|
7
|
+
export declare function lengthPrefixed(parts: Array<string | Buffer>): Buffer;
|
|
8
|
+
export declare function identityDigest(domain: string, ...components: string[]): string;
|
|
9
|
+
export declare function formatId(prefix: string, digestHex: string): string;
|
|
10
|
+
export declare function identityId(prefix: string, domain: string, components: string[]): string;
|
|
11
|
+
export declare function prefixForDomain(domain: string): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation identity-preimage helpers (ADR 0008 D3 twin).
|
|
3
|
+
*
|
|
4
|
+
* Additive helper for golden parity. Does not mint IDs in emit/upload paths.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
export const DOMAINS = {
|
|
8
|
+
runtime_instance: 'netgreener.observation.v1/runtime-instance',
|
|
9
|
+
event: 'netgreener.observation.v1/event',
|
|
10
|
+
batch: 'netgreener.observation.v1/batch',
|
|
11
|
+
runtime_window: 'netgreener.observation.v1/runtime-window',
|
|
12
|
+
};
|
|
13
|
+
const PREFIX_BY_DOMAIN = {
|
|
14
|
+
[DOMAINS.runtime_instance]: 'rti',
|
|
15
|
+
[DOMAINS.event]: 'evt',
|
|
16
|
+
[DOMAINS.batch]: 'bat',
|
|
17
|
+
[DOMAINS.runtime_window]: 'rtw',
|
|
18
|
+
};
|
|
19
|
+
export function lengthPrefixed(parts) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
const raw = typeof part === 'string' ? Buffer.from(part, 'utf8') : part;
|
|
23
|
+
const header = Buffer.alloc(4);
|
|
24
|
+
header.writeUInt32BE(raw.length, 0);
|
|
25
|
+
chunks.push(header, raw);
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks);
|
|
28
|
+
}
|
|
29
|
+
export function identityDigest(domain, ...components) {
|
|
30
|
+
return createHash('sha256')
|
|
31
|
+
.update(lengthPrefixed([domain, ...components]))
|
|
32
|
+
.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
export function formatId(prefix, digestHex) {
|
|
35
|
+
if (digestHex.length !== 64 || !/^[0-9a-f]+$/.test(digestHex)) {
|
|
36
|
+
throw new Error('digest must be 64 lowercase hex chars');
|
|
37
|
+
}
|
|
38
|
+
return `${prefix}_${digestHex}`;
|
|
39
|
+
}
|
|
40
|
+
export function identityId(prefix, domain, components) {
|
|
41
|
+
return formatId(prefix, identityDigest(domain, ...components));
|
|
42
|
+
}
|
|
43
|
+
export function prefixForDomain(domain) {
|
|
44
|
+
const prefix = PREFIX_BY_DOMAIN[domain];
|
|
45
|
+
if (!prefix)
|
|
46
|
+
throw new Error(`unknown identity domain: ${domain}`);
|
|
47
|
+
return prefix;
|
|
48
|
+
}
|