From fc144ab78b58d0c53d8933441c7fc3f6a408071f Mon Sep 17 00:00:00 2001 From: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:14:42 -0500 Subject: [PATCH] fix(projection): validate canonical decimal timestamps in TypeScript hrTime and skip non-finite floats in spanAttributes (#114) Signed-off-by: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> --- conformance/parity/README.md | 5 + conformance/parity/projection-boundaries.json | 123 ++++++++++++++++++ packages/typescript/src/projection.ts | 7 +- packages/typescript/test/metrics.test.ts | 9 ++ .../typescript/test/projection-parity.test.ts | 30 +++++ src/agentrust_telemetry/projection.py | 3 + tests/test_otel_projectors.py | 12 ++ tests/test_projection_parity_corpus.py | 35 +++++ 8 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 conformance/parity/README.md create mode 100644 conformance/parity/projection-boundaries.json create mode 100644 packages/typescript/test/projection-parity.test.ts create mode 100644 tests/test_projection_parity_corpus.py diff --git a/conformance/parity/README.md b/conformance/parity/README.md new file mode 100644 index 0000000..62e26fe --- /dev/null +++ b/conformance/parity/README.md @@ -0,0 +1,5 @@ +# Projection boundary corpus + +Both SDK test suites consume projection-boundaries.json. This slice covers six canonical timestamps, thirteen noncanonical spellings, and five scalar projection cases. Nonfinite values use local special-value tags because they have no JSON representation. These projection inputs are not assertions that arbitrary event fields pass the event schema. + +This companion is based on telemetry #119. Other adapter timestamp/duration and EventFactory corrections remain in #116–120; it neither replaces those fixes nor claims their acceptance. The 20-digit tuple is an explicit positive boundary; logs, delivery, collector behavior and hardware are separate. diff --git a/conformance/parity/projection-boundaries.json b/conformance/parity/projection-boundaries.json new file mode 100644 index 0000000..37f09c6 --- /dev/null +++ b/conformance/parity/projection-boundaries.json @@ -0,0 +1,123 @@ +{ + "timestamps": [ + { + "input": "0", + "tuple": [ + 0, + 0 + ] + }, + { + "input": "1", + "tuple": [ + 0, + 1 + ] + }, + { + "input": "999999999", + "tuple": [ + 0, + 999999999 + ] + }, + { + "input": "1000000000", + "tuple": [ + 1, + 0 + ] + }, + { + "input": "1000000001", + "tuple": [ + 1, + 1 + ] + }, + { + "input": "99999999999999999999", + "tuple": [ + 99999999999, + 999999999 + ] + } + ], + "invalid_timestamps": [ + "", + " ", + "-1", + "+1", + "01", + "0x10", + "1.0", + "1e9", + "1\n", + "1\r", + "1
", + "١", + "111111111111111111111" + ], + "projections": [ + { + "id": "finite-scalars", + "event": { + "run_id": "run-1", + "task_id": 0, + "decision": false + }, + "expected": { + "agentrust.run.id": "run-1", + "agentrust.task.id": 0, + "agentrust.policy.decision": false + } + }, + { + "id": "structured-and-null-omit", + "event": { + "run_id": "run-1", + "task_id": null, + "workflow_id": [], + "agent_id": {} + }, + "expected": { + "agentrust.run.id": "run-1" + } + }, + { + "id": "unmapped-payload-omit", + "event": { + "run_id": "run-1", + "payload": "synthetic" + }, + "expected": { + "agentrust.run.id": "run-1" + } + }, + { + "id": "nonfinite-omit", + "event": { + "run_id": "run-1" + }, + "special": { + "task_id": "nan", + "workflow_id": "positive_infinity", + "agent_id": "negative_infinity" + }, + "expected": { + "agentrust.run.id": "run-1" + } + }, + { + "id": "fractional-scalar", + "event": { + "run_id": "run-1", + "task_id": 1.5 + }, + "expected": { + "agentrust.run.id": "run-1", + "agentrust.task.id": 1.5 + } + } + ] +} diff --git a/packages/typescript/src/projection.ts b/packages/typescript/src/projection.ts index c52bd89..ed29d79 100644 --- a/packages/typescript/src/projection.ts +++ b/packages/typescript/src/projection.ts @@ -14,6 +14,7 @@ export function spanAttributes(event: NormalizedEvent): Record { const result: Record = {}; for (const [source, destination] of Object.entries(DIRECT_ATTRIBUTES)) { const value = event[source]; + if (typeof value === "number" && !Number.isFinite(value)) continue; if (["string", "number", "boolean"].includes(typeof value)) result[destination] = value as Scalar; } return result; @@ -22,5 +23,9 @@ export function logRecord(event: NormalizedEvent, context?: ContextIds): Record< return {event_name: event.event_type, timestamp_ns: event.time_unix_nano, body: structuredClone(event), ...(context ? {trace_id: context.traceId, span_id: context.spanId} : {})}; } export function hrTime(value: string): [number, number] { - const timestamp = BigInt(value); return [Number(timestamp / 1_000_000_000n), Number(timestamp % 1_000_000_000n)]; + if (typeof value !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(value)) throw new Error("Unix nanosecond timestamps must be canonical non-negative decimals of at most 20 digits"); + const timestamp = BigInt(value); + const seconds = timestamp / 1_000_000_000n; + if (seconds > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("timestamp seconds exceed safe integer range"); + return [Number(seconds), Number(timestamp % 1_000_000_000n)]; } diff --git a/packages/typescript/test/metrics.test.ts b/packages/typescript/test/metrics.test.ts index 2feb8c2..c902628 100644 --- a/packages/typescript/test/metrics.test.ts +++ b/packages/typescript/test/metrics.test.ts @@ -32,3 +32,12 @@ test("policy, approval, and action metrics expose only bounded semantic dimensio for (const name of ["policy-decision.json", "approval.json", "action.json"]) assert.equal(emitter.emit(fixture(name)), true); assert.deepEqual(Object.keys(meter.instruments.get("agentrust.action.executions")!.calls[0]!.attributes!).sort(), ["agentrust.action.kind", "agentrust.action.outcome"]); }); + +test("hrTime rejects malformed timestamps and spanAttributes skips non-finite numbers", async () => { + const {hrTime, spanAttributes} = await import("../src/projection.js"); + for (const bad of ["", " ", "-1", "0x10", "01", "1".repeat(21)]) { + assert.throws(() => hrTime(bad), /canonical non-negative decimals/); + } + const attrs = spanAttributes({run_id: "r1", task_id: Number.NaN, workflow_id: Number.POSITIVE_INFINITY} as unknown as NormalizedEvent); + assert.deepEqual(attrs, {"agentrust.run.id": "r1"}); +}); diff --git a/packages/typescript/test/projection-parity.test.ts b/packages/typescript/test/projection-parity.test.ts new file mode 100644 index 0000000..78238ee --- /dev/null +++ b/packages/typescript/test/projection-parity.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import {readFileSync} from "node:fs"; +import test from "node:test"; +import {hrTime, spanAttributes} from "../src/projection.js"; +import type {NormalizedEvent} from "../src/types.js"; + +const corpus = JSON.parse(readFileSync( + new URL("../../../conformance/parity/projection-boundaries.json", import.meta.url), "utf8", +)); +for (const row of corpus.timestamps) { + test("shared timestamp " + row.input, () => assert.deepEqual(hrTime(row.input), row.tuple)); +} +for (const value of corpus.invalid_timestamps) { + test("shared noncanonical timestamp " + JSON.stringify(value), () => { + assert.throws(() => hrTime(value)); + }); +} +const special: Record = { + nan: Number.NaN, positive_infinity: Number.POSITIVE_INFINITY, + negative_infinity: Number.NEGATIVE_INFINITY, +}; +for (const row of corpus.projections) { + test("shared scalar projection " + row.id, () => { + const event = {...row.event}; + for (const [key, value] of Object.entries(row.special ?? {})) { + event[key] = special[value as string]; + } + assert.deepEqual(spanAttributes(event as NormalizedEvent), row.expected); + }); +} diff --git a/src/agentrust_telemetry/projection.py b/src/agentrust_telemetry/projection.py index 258108f..4d88cb9 100644 --- a/src/agentrust_telemetry/projection.py +++ b/src/agentrust_telemetry/projection.py @@ -3,6 +3,7 @@ from __future__ import annotations from copy import deepcopy +import math from typing import Any from .context import ContextIds @@ -32,6 +33,8 @@ def span_attributes(event: dict[str, Any]) -> dict[str, str | int | float | bool projected: dict[str, str | int | float | bool] = {} for source, destination in DIRECT_ATTRIBUTES.items(): value = event.get(source) + if isinstance(value, float) and not math.isfinite(value): + continue if isinstance(value, (str, int, float, bool)): projected[destination] = value return projected diff --git a/tests/test_otel_projectors.py b/tests/test_otel_projectors.py index 1dbc7c9..c5dd302 100644 --- a/tests/test_otel_projectors.py +++ b/tests/test_otel_projectors.py @@ -159,6 +159,18 @@ def test_action_metrics_exclude_action_and_agent_identifiers(self): {"agentrust.action.kind", "agentrust.action.outcome"}, ) + def test_span_attributes_excludes_nonfinite_floats(self): + from agentrust_telemetry.projection import span_attributes + + attrs = span_attributes( + { + "run_id": "r-1", + "task_id": float("nan"), + "workflow_id": float("inf"), + } + ) + self.assertEqual(attrs, {"agentrust.run.id": "r-1"}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_projection_parity_corpus.py b/tests/test_projection_parity_corpus.py new file mode 100644 index 0000000..430c972 --- /dev/null +++ b/tests/test_projection_parity_corpus.py @@ -0,0 +1,35 @@ +"""Shared Python/TypeScript normalization and projection cases, not schema acceptance.""" +import json +import math +import unittest +from pathlib import Path + +from agentrust_telemetry.adapters.base import _unix_nano +from agentrust_telemetry.projection import span_attributes + +CORPUS = json.loads( + (Path(__file__).resolve().parents[1] / "conformance/parity/projection-boundaries.json") + .read_text(encoding="utf-8") +) + + +class TestProjectionParityCorpus(unittest.TestCase): + def test_timestamp_normalization(self): + for case in CORPUS["timestamps"]: + with self.subTest(value=case["input"]): + normalized = _unix_nano(case["input"]) + self.assertEqual(normalized, case["input"]) + self.assertEqual(list(divmod(int(normalized), 1_000_000_000)), case["tuple"]) + + def test_noncanonical_timestamps(self): + for value in CORPUS["invalid_timestamps"]: + with self.subTest(value=value), self.assertRaises((ValueError, TypeError)): + _unix_nano(value) + + def test_scalar_projection(self): + special = {"nan": math.nan, "positive_infinity": math.inf, "negative_infinity": -math.inf} + for case in CORPUS["projections"]: + with self.subTest(case=case["id"]): + event = dict(case["event"]) + event.update({key: special[value] for key, value in case.get("special", {}).items()}) + self.assertEqual(span_attributes(event), case["expected"])