diff --git a/docs/doctoring/people-api-operational-telemetry-references.md b/docs/doctoring/people-api-operational-telemetry-references.md new file mode 100644 index 000000000..5c7625d5c --- /dev/null +++ b/docs/doctoring/people-api-operational-telemetry-references.md @@ -0,0 +1,20 @@ +# People API operational telemetry references + +Accessed 2026-08-23. These references are engineering evidence for PR #90; they do not assert OpenTelemetry certification or complete Semantic Conventions conformance. + +## APA 7 references + +OpenTelemetry Authors. (2026). *OpenTelemetry semantic conventions 1.44.0*. OpenTelemetry. https://opentelemetry.io/docs/specs/semconv/ + +OpenTelemetry Authors. (2026). *Semantic conventions for HTTP metrics*. OpenTelemetry. https://opentelemetry.io/docs/specs/semconv/http/http-metrics/ + +OpenTelemetry Authors. (2026). *Semantic conventions for HTTP spans*. OpenTelemetry. https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + +## Design consequences used by this slice + +- The current Semantic Conventions release is 1.44.0. +- HTTP server request duration is represented by the stable `http.server.request.duration` metric in seconds; this slice stores a duration in seconds but leaves histogram/export configuration to the deployment adapter. +- A request method unknown to instrumentation maps to `_OTHER`, and method values are case-sensitive. PR #90 therefore does not place arbitrary request method text into its normal metric dimension. +- `http.route` is intended to be a low-cardinality application route and raw URI paths must not be substituted for it. PR #90 therefore recognizes only application-owned People route templates and emits no route value for unknown paths. +- `error.type` should be predictable and low-cardinality. Successful requests should not set it. PR #90 uses only decimal 5xx status strings plus two bounded middleware states (`unhandled_exception` and `missing_response_status`). +- The OpenTelemetry HTTP conventions include attributes beyond the internal measurement in this slice. A future exporter adapter must satisfy those current requirements itself and must not infer missing values by copying sensitive request metadata into metrics. diff --git a/docs/traceability/people-api-operational-telemetry.md b/docs/traceability/people-api-operational-telemetry.md new file mode 100644 index 000000000..32b4001df --- /dev/null +++ b/docs/traceability/people-api-operational-telemetry.md @@ -0,0 +1,38 @@ +# People API operational telemetry traceability + +## Truth status + +- **Protected-main truth:** `develop@9e3e4847510e1e612b48474ba42b177b8ed824df` exposes governed People HTTP boundaries and candidate SLOs, but it does not contain a People HTTP measurement middleware or an OpenTelemetry exporter. +- **Active PR truth:** PR #90 adds an adapter-neutral, privacy-minimized request-completion measurement boundary in `orgmetra_people_api.telemetry`. +- **Not claimed:** this slice does not configure an OpenTelemetry SDK/Collector, export OTLP, publish dashboards or alerts, prove an SLO, instrument database calls, or make a release/deployment claim. + +## Buyer-visible requirement to executable evidence + +| Requirement | Executable boundary | Regression evidence | +| --- | --- | --- | +| Request latency can be measured without copying HR identifiers | `PeopleHttpTelemetryMiddleware` emits `duration_seconds` with only a known route template or `None` | `test_emits_duration_without_identifying_request_values`, `test_unknown_route_never_uses_raw_path_as_metric_route` | +| Route dimensions stay low-cardinality | `classify_people_http_route` recognizes only five application-owned People route templates and never substitutes a raw path | `test_classifies_only_known_low_cardinality_people_routes` | +| HTTP methods stay bounded | `normalize_http_method` emits the reviewed known method set or `_OTHER`; exact built-in strings prevent hostile runtime equality/hash behavior | `test_normalizes_unknown_or_runtime_subclass_methods_to_other` | +| Server errors are aggregatable without backend exception disclosure | HTTP 5xx uses its decimal status string; any propagated exception uses `unhandled_exception` regardless of response-status capture; a missing response start uses `missing_response_status` | `test_records_server_error_as_low_cardinality_status_error_type`, `test_unhandled_exception_is_measured_then_reraised`, `test_missing_response_start_is_bounded_operational_error` | +| Successful/client-error requests do not manufacture server-error dimensions | status < 500 leaves `error_type` unset | `test_does_not_mark_client_error_as_server_failure` | +| Telemetry outage cannot deny governed HR work | sink/measurement failures are caught after the wrapped request outcome is determined; only bounded operator metadata is logged | `test_exporter_failure_never_breaks_people_response` | +| ASGI non-HTTP scopes are not mislabeled as HTTP traffic | non-HTTP scopes pass directly to the wrapped app | `test_non_http_scope_passes_through_without_measurement` | +| Invalid direct measurement construction fails closed | `PeopleHttpRequestMeasurement` validates exact types, finite duration, status range, route allow-list and error/status consistency | `test_measurement_rejects_unbounded_or_noncanonical_dimensions` | +| Middleware wiring fails before traffic when dependencies are unusable | constructor validates wrapped app, metric sink method and monotonic clock callability | `test_rejects_unusable_middleware_dependencies_before_traffic` | +| Response status capture is deterministic | middleware records the first valid integer HTTP response status and ignores invalid/duplicate starts | `test_ignores_invalid_and_duplicate_response_start_statuses` | + +## Privacy and cardinality boundary + +The measurement deliberately excludes tenant, Person, Candidate, Employment, Position and Assignment identifiers; raw URL/path values; query strings; headers; bearer credentials; actor references; request/response bodies; HR values; support references; exception messages; database details; and foreign-service identifiers. Unknown routes produce `route_template=None` rather than a caller-controlled string. + +The sink receives a completed immutable measurement. Exporters may translate that measurement to their backend, but they must not enrich it with PII or uncontrolled request attributes. Export is non-authoritative and best-effort: telemetry loss is operationally visible but cannot change authorization, mutation, read, hire, audit/outbox, or HTTP outcome semantics. + +## OpenTelemetry alignment boundary + +The design is grounded in OpenTelemetry Semantic Conventions 1.44.0. It follows the stable HTTP server guidance that `http.server.request.duration` is measured in seconds, unknown request methods map to `_OTHER`, `http.route` is a low-cardinality route template rather than a raw path, and `error.type` is predictable and low-cardinality. + +This package is **not** an OpenTelemetry instrumentation library and does not claim full Semantic Conventions conformance. A deployment adapter that maps the internal measurement to OpenTelemetry remains responsible for the current required/recommended resource/network attributes, exporter configuration, Collector policy, any deployment-specific override of recognized HTTP methods, histogram aggregation, temporality and retention. Those concerns must not be implemented by copying request PII into metric attributes. + +## Owner boundaries + +This slice writes only Orgmetra. Keyverse, Naruon, contextual-orchestrator, Psychometrics Commons, TEPP and other dedicated-writer CWL repositories remain read-only dependencies. No cross-service application-table SQL is introduced. diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..d188d471f 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -17,3 +17,19 @@ The People API quality workflow is part of this contract and must run for pull r `PeopleMutationAsgiApp` exposes the governed People mutation API as `POST /v1/employment-records`, `POST /v1/position-records`, and `POST /v1/assignment-records`. Each command requires an idempotency key, tenant/actor/purpose headers, a non-blank accountable decision reason, human confirmation, and versioned evidence. The HTTP boundary enforces the exact OpenAPI evidence-object shape and cardinality, rejects additional fields and duplicate evidence items, and canonicalizes the complete reference/version set independent of array order. It first derives a PII-minimized `evidence_set_v1:` identity and then binds that identity together with the exact validated decision reason into `governance_evidence_v1:`. The free-text reason and raw evidence references are not copied into the portable audit envelope, but any reason/reference/version drift changes the governance binding, the immutable audit correlation evidence, and the durable idempotency command digest. A caller therefore cannot reuse the same key after silently changing the high-impact rationale and receive an incorrect replay. The validated `Idempotency-Key` is copied onto the application command and into `PostgresPeopleMutationPort`. Employment and assignment writes require a current `candidate_worker_conversion_record` (`recorded_to IS NULL`) and reuse `orgmetra_hris_kernel` exclusivity and assignment-coverage checks before the port inserts the authoritative fact, calls `record_audit_outbox_event`, and stores `people_mutation_idempotency_record` in the same transaction. A matching retry returns the first committed identity without a second HRIS, audit, or outbox fact. Successful responses contain only opaque record identifiers. The superseded persistence model must not be restored, and the service must not use direct cross-service application-table SQL. + +## Operational telemetry wiring + +`PeopleHttpTelemetryMiddleware` is the governed composition point for privacy-safe People HTTP telemetry. Deployment adapters wrap each mounted ASGI app exactly once so live traffic emits one bounded `http.server.request.duration` measurement without ever placing tenant, person, candidate, raw path, query, header, credential, or payload values into metric dimensions: + +```python +from orgmetra_people_api import ( + PeopleAsgiApp, + PeopleHttpTelemetryMiddleware, +) + +governed_reads = PeopleAsgiApp(authenticator=..., policy=..., read_port=...) +served_app = PeopleHttpTelemetryMiddleware(app=governed_reads, sink=your_metric_sink) +``` + +The middleware never changes request status or exception behavior: exporter, route-classification, and even clock-source failures degrade telemetry to a value-free warning record while the wrapped HR request completes normally. Route labels are restricted to the application-owned low-cardinality templates in `classify_people_http_route`; every other path is recorded with no route label rather than a raw URL. diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index b043bed33..ff1f41ebe 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -43,6 +43,12 @@ from orgmetra_people_api.postgres import PostgresPeopleReadPort from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from orgmetra_people_api.telemetry import ( + PeopleHttpRequestMeasurement, + PeopleHttpTelemetryMiddleware, + classify_people_http_route, + normalize_http_method, +) __all__ = [ "AuthenticatedPrincipal", @@ -59,6 +65,8 @@ "PeopleMutationIntegrityError", "PeopleMutationNotFound", "PeopleMutationPort", + "PeopleHttpRequestMeasurement", + "PeopleHttpTelemetryMiddleware", "PeopleReadPort", "PeopleRecordIntegrityError", "PeopleRecordNotFound", @@ -75,9 +83,11 @@ "WorkerPeopleRecord", "accept_confirmed_hire", "authorize_resource_fields", + "classify_people_http_route", "create_assignment_record", "create_employment_record", "create_position_record", "extract_bearer_token", + "normalize_http_method", "read_worker_people_record", ] diff --git a/services/people-api/src/orgmetra_people_api/telemetry.py b/services/people-api/src/orgmetra_people_api/telemetry.py new file mode 100644 index 000000000..918e4dcba --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/telemetry.py @@ -0,0 +1,254 @@ +"""Privacy-safe, low-cardinality operational telemetry for the People HTTP boundary. + +This module deliberately does not depend on an OpenTelemetry SDK or exporter. It +captures one stable request-duration measurement that a deployment adapter can +map to ``http.server.request.duration`` while keeping HR identifiers, raw paths, +query strings, headers, credentials, payload values, and backend exception text +out of metric dimensions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +import logging +from time import perf_counter +from typing import Awaitable, Callable, Mapping, Protocol + +AsgiReceive = Callable[[], Awaitable[dict[str, object]]] +AsgiSend = Callable[[dict[str, object]], Awaitable[None]] +AsgiApp = Callable[[Mapping[str, object], AsgiReceive, AsgiSend], Awaitable[None]] +Clock = Callable[[], float] + +_LOGGER = logging.getLogger(__name__) +_MAX_ROUTE_PATH_CHARACTERS = 256 +_KNOWN_HTTP_METHODS = frozenset( + { + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "QUERY", + "TRACE", + "_OTHER", + } +) +_PEOPLE_ROUTE_TEMPLATES = frozenset( + { + "/v1/tenants/{tenant_record_id}/people/{person_record_id}", + "/v1/tenants/{tenant_record_id}/candidate-worker-conversions", + "/v1/employment-records", + "/v1/position-records", + "/v1/assignment-records", + } +) +_BOUNDED_ERROR_TYPES = frozenset({"unhandled_exception", "missing_response_status"}) + + +class PeopleMetricSink(Protocol): + """Receive one privacy-minimized HTTP server request measurement.""" + + def record_http_server_request(self, measurement: "PeopleHttpRequestMeasurement") -> None: + """Record one request-duration measurement without changing request outcome.""" + + +@dataclass(frozen=True, slots=True) +class PeopleHttpRequestMeasurement: + """Represent one bounded HTTP request-duration sample for operational use. + + ``route_template`` is either one application-owned template or ``None``; it + is never a raw URL path. ``error_type`` is either a bounded middleware state + or the decimal HTTP 5xx status. These constraints make aggregation useful + without creating tenant/person/candidate-level metric cardinality. + """ + + method: str + route_template: str | None + status_code: int | None + duration_seconds: float + error_type: str | None + + def __post_init__(self) -> None: + """Reject direct construction that could create unsafe metric dimensions.""" + if type(self.method) is not str or self.method not in _KNOWN_HTTP_METHODS: + raise ValueError("method must be one canonical known HTTP method or _OTHER") + if self.route_template is not None and ( + type(self.route_template) is not str + or self.route_template not in _PEOPLE_ROUTE_TEMPLATES + ): + raise ValueError("route_template must be one bounded People route template or None") + if self.status_code is not None and ( + type(self.status_code) is not int or not 100 <= self.status_code <= 599 + ): + raise ValueError("status_code must be an HTTP status integer or None") + if ( + type(self.duration_seconds) is not float + or not isfinite(self.duration_seconds) + or self.duration_seconds < 0.0 + ): + raise ValueError("duration_seconds must be one finite non-negative float") + self._validate_error_type() + + def _validate_error_type(self) -> None: + """Keep server-error dimensions finite and internally consistent.""" + if self.error_type is None: + return + if type(self.error_type) is not str: + raise ValueError("error_type must be one bounded error code or None") + if self.error_type in _BOUNDED_ERROR_TYPES: + return + if ( + self.status_code is None + or self.status_code < 500 + or self.error_type != str(self.status_code) + ): + raise ValueError("error_type must be a matching HTTP 5xx status or bounded error code") + + +def normalize_http_method(value: object) -> str: + """Map a request method to the current finite OpenTelemetry-known method set.""" + if type(value) is str and value in _KNOWN_HTTP_METHODS and value != "_OTHER": + return value + return "_OTHER" + + +def classify_people_http_route(path: object) -> str | None: + """Return a low-cardinality People route template without exposing raw path data.""" + if type(path) is not str or len(path) > _MAX_ROUTE_PATH_CHARACTERS: + return None + parts = path.strip("/").split("/") + if ( + len(parts) == 5 + and parts[0] == "v1" + and parts[1] == "tenants" + and bool(parts[2]) + and parts[3] == "people" + and bool(parts[4]) + ): + return "/v1/tenants/{tenant_record_id}/people/{person_record_id}" + if ( + len(parts) == 4 + and parts[0] == "v1" + and parts[1] == "tenants" + and bool(parts[2]) + and parts[3] == "candidate-worker-conversions" + ): + return "/v1/tenants/{tenant_record_id}/candidate-worker-conversions" + if len(parts) == 2 and parts[0] == "v1": + static_route = f"/v1/{parts[1]}" + if static_route in _PEOPLE_ROUTE_TEMPLATES: + return static_route + return None + + +@dataclass(frozen=True, slots=True) +class PeopleHttpTelemetryMiddleware: + """Measure one wrapped People ASGI app without making telemetry authoritative. + + Export is deliberately best-effort: a sink/configuration failure is logged + with bounded metadata and never changes the wrapped HR request's status or + exception behavior. Non-HTTP ASGI scopes pass through without HTTP metrics. + """ + + app: AsgiApp + sink: PeopleMetricSink + clock: Clock = perf_counter + + def __post_init__(self) -> None: + """Reject unusable dependency injection before accepting traffic.""" + if not callable(self.app): + raise TypeError("app must be callable") + if not callable(getattr(self.sink, "record_http_server_request", None)): + raise TypeError("sink must implement record_http_server_request") + if not callable(self.clock): + raise TypeError("clock must be callable") + + async def __call__( + self, + scope: Mapping[str, object], + receive: AsgiReceive, + send: AsgiSend, + ) -> None: + """Run the wrapped app and emit one privacy-minimized completion measurement.""" + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + started_at: float | None + try: + started_at = self.clock() + except Exception: # noqa: BLE001 - telemetry must never become HR request authority. + started_at = None + status_code: int | None = None + + async def measured_send(message: dict[str, object]) -> None: + """Capture only the first valid response status before forwarding the frame.""" + nonlocal status_code + if status_code is None and message.get("type") == "http.response.start": + candidate = message.get("status") + if type(candidate) is int and 100 <= candidate <= 599: + status_code = candidate + await send(message) + + try: + await self.app(scope, receive, measured_send) + except Exception: + self._record_completion( + scope=scope, + started_at=started_at, + status_code=status_code, + error_type="unhandled_exception", + ) + raise + + error_type: str | None + if status_code is None: + error_type = "missing_response_status" + elif status_code >= 500: + error_type = str(status_code) + else: + error_type = None + self._record_completion( + scope=scope, + started_at=started_at, + status_code=status_code, + error_type=error_type, + ) + + def _record_completion( + self, + *, + scope: Mapping[str, object], + started_at: float | None, + status_code: int | None, + error_type: str | None, + ) -> None: + """Best-effort emit one bounded measurement without leaking request values.""" + if started_at is None: + self._warn_measurement_not_exported() + return + try: + duration_seconds = float(self.clock() - started_at) + measurement = PeopleHttpRequestMeasurement( + method=normalize_http_method(scope.get("method")), + route_template=classify_people_http_route(scope.get("path")), + status_code=status_code, + duration_seconds=duration_seconds, + error_type=error_type, + ) + self.sink.record_http_server_request(measurement) + except Exception: # noqa: BLE001 - telemetry must never become HR request authority. + self._warn_measurement_not_exported() + + def _warn_measurement_not_exported(self) -> None: + """Log one bounded, value-free telemetry degradation record.""" + _LOGGER.warning( + "People HTTP telemetry measurement was not exported", + extra={ + "telemetry_event": "http_server_request_measurement_rejected", + }, + ) diff --git a/services/people-api/tests/test_operational_telemetry.py b/services/people-api/tests/test_operational_telemetry.py new file mode 100644 index 000000000..9bc4e2bf4 --- /dev/null +++ b/services/people-api/tests/test_operational_telemetry.py @@ -0,0 +1,431 @@ +"""Regression coverage for privacy-safe, low-cardinality People HTTP telemetry.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from dataclasses import dataclass, field +import logging +from typing import Any + +import pytest + +from orgmetra_people_api.telemetry import ( + PeopleHttpRequestMeasurement, + PeopleHttpTelemetryMiddleware, + classify_people_http_route, + normalize_http_method, +) + + +@dataclass +class _RecordingSink: + """Collect emitted measurements without adding a telemetry dependency.""" + + measurements: list[PeopleHttpRequestMeasurement] = field(default_factory=list) + fail: bool = False + + def record_http_server_request(self, measurement: PeopleHttpRequestMeasurement) -> None: + """Record one request measurement or simulate an exporter outage.""" + if self.fail: + raise RuntimeError("exporter unavailable") + self.measurements.append(measurement) + + +class _Clock: + """Return deterministic monotonic samples for middleware tests.""" + + def __init__(self, samples: Iterable[float]) -> None: + """Store a finite sequence of deterministic samples.""" + self._samples = iter(samples) + + def __call__(self) -> float: + """Return the next deterministic clock sample.""" + return next(self._samples) + + +def _scope(*, method: object = "GET", path: object = "/v1/employment-records") -> dict[str, object]: + """Build one minimal HTTP ASGI scope for telemetry tests.""" + return {"type": "http", "method": method, "path": path} + + +async def _receive() -> dict[str, object]: + """Return an empty terminal request-body frame.""" + return {"type": "http.request", "body": b"", "more_body": False} + + +def _run(app: PeopleHttpTelemetryMiddleware, scope: dict[str, object]) -> list[dict[str, object]]: + """Execute one middleware request and return downstream ASGI messages.""" + sent: list[dict[str, object]] = [] + + async def send(message: dict[str, object]) -> None: + """Collect one downstream ASGI message.""" + sent.append(message) + + asyncio.run(app(scope, _receive, send)) + return sent + + +def _success_app(status: int = 200): + """Build a downstream ASGI app that emits one bounded response.""" + + async def app(scope: dict[str, object], receive: Any, send: Any) -> None: + """Emit one response start and one empty body.""" + del scope, receive + await send({"type": "http.response.start", "status": status, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + return app + + +def test_classifies_only_known_low_cardinality_people_routes() -> None: + """Never copy tenant/person identifiers or arbitrary paths into metric labels.""" + tenant = "018f46d3-5d20-7d44-a3c0-7ae917d96534" + person = "c18e31fd-1ab6-40b8-9ee6-866fed735ee1" + + assert classify_people_http_route(f"/v1/tenants/{tenant}/people/{person}") == ( + "/v1/tenants/{tenant_record_id}/people/{person_record_id}" + ) + assert classify_people_http_route(f"/v1/tenants/{tenant}/candidate-worker-conversions") == ( + "/v1/tenants/{tenant_record_id}/candidate-worker-conversions" + ) + assert classify_people_http_route("/v1/employment-records") == "/v1/employment-records" + assert classify_people_http_route("/v1/position-records") == "/v1/position-records" + assert classify_people_http_route("/v1/assignment-records") == "/v1/assignment-records" + assert classify_people_http_route(f"/v1/tenants/{tenant}/secret/{person}") is None + assert classify_people_http_route("/v1/tenants//people/value") is None + assert classify_people_http_route("/v1/tenants//candidate-worker-conversions") is None + assert classify_people_http_route("/v1/not-a-people-route") is None + assert classify_people_http_route("/" + "x" * 300) is None + assert classify_people_http_route(123) is None + + +def test_normalizes_unknown_or_runtime_subclass_methods_to_other() -> None: + """Keep the method label finite and avoid executing caller-controlled equality.""" + + class ForgedMethod(str): + """Represent hostile request text whose equality must never be trusted.""" + + def __eq__(self, other: object) -> bool: + """Pretend to be any known method.""" + return True + + def __hash__(self) -> int: + """Pretend to hash like GET.""" + return hash("GET") + + assert normalize_http_method("GET") == "GET" + assert normalize_http_method("POST") == "POST" + assert normalize_http_method("BREW") == "_OTHER" + assert normalize_http_method("_OTHER") == "_OTHER" + assert normalize_http_method(ForgedMethod("BREW")) == "_OTHER" + assert normalize_http_method(None) == "_OTHER" + + +def test_rejects_unusable_middleware_dependencies_before_traffic() -> None: + """Fail fast when the wrapped app, sink, or monotonic clock cannot be called.""" + sink = _RecordingSink() + + with pytest.raises(TypeError, match="app"): + PeopleHttpTelemetryMiddleware(app=None, sink=sink) # type: ignore[arg-type] + with pytest.raises(TypeError, match="sink"): + PeopleHttpTelemetryMiddleware(app=_success_app(), sink=object()) # type: ignore[arg-type] + with pytest.raises(TypeError, match="clock"): + PeopleHttpTelemetryMiddleware( + app=_success_app(), sink=sink, clock=None # type: ignore[arg-type] + ) + + +def test_emits_duration_without_identifying_request_values() -> None: + """Emit only a route template, method, status, duration, and bounded error state.""" + sink = _RecordingSink() + tenant = "018f46d3-5d20-7d44-a3c0-7ae917d96534" + person = "c18e31fd-1ab6-40b8-9ee6-866fed735ee1" + app = PeopleHttpTelemetryMiddleware( + app=_success_app(), sink=sink, clock=_Clock([10.0, 10.125]) + ) + + _run(app, _scope(path=f"/v1/tenants/{tenant}/people/{person}")) + + assert sink.measurements == [ + PeopleHttpRequestMeasurement( + method="GET", + route_template="/v1/tenants/{tenant_record_id}/people/{person_record_id}", + status_code=200, + duration_seconds=0.125, + error_type=None, + ) + ] + rendered = repr(sink.measurements[0]) + assert tenant not in rendered + assert person not in rendered + + +def test_records_server_error_as_low_cardinality_status_error_type() -> None: + """Represent server failures by bounded HTTP status rather than backend exception text.""" + sink = _RecordingSink() + app = PeopleHttpTelemetryMiddleware( + app=_success_app(503), sink=sink, clock=_Clock([4.0, 4.5]) + ) + + _run(app, _scope(method="POST", path="/v1/employment-records")) + + assert sink.measurements[0].status_code == 503 + assert sink.measurements[0].error_type == "503" + + +def test_does_not_mark_client_error_as_server_failure() -> None: + """Keep a normal 4xx response out of the server-error dimension.""" + sink = _RecordingSink() + app = PeopleHttpTelemetryMiddleware( + app=_success_app(403), sink=sink, clock=_Clock([2.0, 2.25]) + ) + + _run(app, _scope(method="POST", path="/v1/assignment-records")) + + assert sink.measurements[0].status_code == 403 + assert sink.measurements[0].error_type is None + + +def test_unknown_route_never_uses_raw_path_as_metric_route() -> None: + """Omit http.route when the application route template is not known.""" + sink = _RecordingSink() + raw_path = "/customer/acme/employee/alice@example.com" + app = PeopleHttpTelemetryMiddleware( + app=_success_app(404), sink=sink, clock=_Clock([1.0, 1.1]) + ) + + _run(app, _scope(path=raw_path)) + + measurement = sink.measurements[0] + assert measurement.route_template is None + assert raw_path not in repr(measurement) + + +def test_ignores_invalid_and_duplicate_response_start_statuses() -> None: + """Measure the first valid status without trusting bools or later duplicate starts.""" + sink = _RecordingSink() + + async def unusual_app(scope: Any, receive: Any, send: Any) -> None: + """Emit an invalid status, then the first valid status, then a duplicate start.""" + del scope, receive + await send({"type": "http.response.start", "status": True, "headers": []}) + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.start", "status": 503, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + app = PeopleHttpTelemetryMiddleware( + app=unusual_app, sink=sink, clock=_Clock([6.0, 6.2]) + ) + + _run(app, _scope()) + + assert sink.measurements[0].status_code == 204 + assert sink.measurements[0].error_type is None + + +def test_unhandled_exception_is_measured_then_reraised() -> None: + """Keep failure observability without turning telemetry into exception handling.""" + sink = _RecordingSink() + + async def failing_app(scope: Any, receive: Any, send: Any) -> None: + """Raise before an HTTP status is emitted.""" + del scope, receive, send + raise LookupError("sensitive backend detail") + + app = PeopleHttpTelemetryMiddleware( + app=failing_app, sink=sink, clock=_Clock([8.0, 8.2]) + ) + + with pytest.raises(LookupError, match="sensitive backend detail"): + _run(app, _scope()) + + measurement = sink.measurements[0] + assert measurement.status_code is None + assert measurement.error_type == "unhandled_exception" + assert "LookupError" not in repr(measurement) + assert "sensitive backend detail" not in repr(measurement) + + +def test_missing_response_start_is_bounded_operational_error() -> None: + """Flag a downstream ASGI contract failure without copying arbitrary details.""" + sink = _RecordingSink() + + async def missing_start_app(scope: Any, receive: Any, send: Any) -> None: + """Return a body without an HTTP response-start frame.""" + del scope, receive + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + app = PeopleHttpTelemetryMiddleware( + app=missing_start_app, sink=sink, clock=_Clock([3.0, 3.1]) + ) + + _run(app, _scope()) + + assert sink.measurements[0].status_code is None + assert sink.measurements[0].error_type == "missing_response_status" + + +def test_exporter_failure_never_breaks_people_response( + caplog: pytest.LogCaptureFixture, +) -> None: + """Keep telemetry best-effort so exporter outages cannot deny governed HR work.""" + sink = _RecordingSink(fail=True) + app = PeopleHttpTelemetryMiddleware( + app=_success_app(), sink=sink, clock=_Clock([5.0, 5.01]) + ) + + with caplog.at_level(logging.WARNING, logger="orgmetra_people_api.telemetry"): + sent = _run(app, _scope()) + + assert sent[0]["status"] == 200 + assert sink.measurements == [] + records = [ + record + for record in caplog.records + if getattr(record, "telemetry_event", None) + == "http_server_request_measurement_rejected" + ] + assert len(records) == 1 + assert records[0].getMessage() == "People HTTP telemetry measurement was not exported" + assert "exporter unavailable" not in records[0].getMessage() + + +def test_non_http_scope_passes_through_without_measurement() -> None: + """Do not attach HTTP metric semantics to lifespan or other ASGI scopes.""" + sink = _RecordingSink() + called: list[str] = [] + + async def lifespan_app(scope: Any, receive: Any, send: Any) -> None: + """Record that the non-HTTP scope reached the wrapped application.""" + del receive, send + called.append(str(scope["type"])) + + app = PeopleHttpTelemetryMiddleware( + app=lifespan_app, sink=sink, clock=_Clock([]) + ) + asyncio.run(app({"type": "lifespan"}, _receive, lambda message: None)) + + assert called == ["lifespan"] + assert sink.measurements == [] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"method": "get"}, "method"), + ({"route_template": "/raw/customer/123"}, "route_template"), + ({"status_code": 99}, "status_code"), + ({"duration_seconds": -0.1}, "duration_seconds"), + ({"error_type": 503}, "error_type"), + ({"error_type": "database-secret"}, "error_type"), + ({"status_code": 200, "error_type": "503"}, "error_type"), + ], +) +def test_measurement_rejects_unbounded_or_noncanonical_dimensions( + kwargs: dict[str, object], message: str +) -> None: + """Fail closed when direct construction could create unsafe metric dimensions.""" + values: dict[str, object] = { + "method": "GET", + "route_template": "/v1/employment-records", + "status_code": 200, + "duration_seconds": 0.1, + "error_type": None, + } + values.update(kwargs) + + with pytest.raises((TypeError, ValueError), match=message): + PeopleHttpRequestMeasurement(**values) # type: ignore[arg-type] + + +class _ExplodingClock: + """Simulate a telemetry clock source that fails on every sample.""" + + def __call__(self) -> float: + """Raise to model an unavailable monotonic clock source.""" + raise RuntimeError("clock source unavailable") + + +def test_clock_failure_at_request_start_never_blocks_the_wrapped_hr_request() -> None: + """A failing start-time clock must not propagate into the served HR request.""" + sink = _RecordingSink() + middleware = PeopleHttpTelemetryMiddleware( + app=_success_app(), + sink=sink, + clock=_ExplodingClock(), + ) + + sent = _run(middleware, _scope()) + + starts = [m.get("status") for m in sent if m.get("type") == "http.response.start"] + assert starts == [200] + assert sink.measurements == [] + + +def test_clock_failure_at_completion_is_swallowed_without_export() -> None: + """A failing completion-time clock degrades telemetry instead of the request.""" + sink = _RecordingSink() + + class _HalfBrokenClock: + """Serve one start sample, then fail for every later sample.""" + + def __init__(self) -> None: + """Arm exactly one successful start-time sample.""" + self._calls = 0 + + def __call__(self) -> float: + """Return the deterministic start sample once, then raise.""" + self._calls += 1 + if self._calls == 1: + return 10.0 + raise RuntimeError("clock source unavailable") + + middleware = PeopleHttpTelemetryMiddleware( + app=_success_app(), + sink=sink, + clock=_HalfBrokenClock(), + ) + + sent = _run(middleware, _scope()) + + starts = [m.get("status") for m in sent if m.get("type") == "http.response.start"] + assert starts == [200] + assert sink.measurements == [] + + +def test_telemetry_surface_is_importable_from_package_root() -> None: + """Deployment adapters must compose telemetry through the public package surface.""" + from orgmetra_people_api import ( # noqa: PLC0415 - import-surface regression + PeopleHttpRequestMeasurement as ExportedMeasurement, + PeopleHttpTelemetryMiddleware as ExportedMiddleware, + classify_people_http_route as ExportedClassifier, + normalize_http_method as ExportedNormalizer, + ) + + assert ExportedMiddleware is PeopleHttpTelemetryMiddleware + assert ExportedMeasurement is PeopleHttpRequestMeasurement + assert ExportedClassifier is classify_people_http_route + assert ExportedNormalizer is normalize_http_method + + +def test_clock_failure_at_request_start_logs_degradation_exactly_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """One degraded request must emit exactly one value-free warning record.""" + sink = _RecordingSink() + middleware = PeopleHttpTelemetryMiddleware( + app=_success_app(), + sink=sink, + clock=_ExplodingClock(), + ) + + with caplog.at_level(logging.WARNING, logger="orgmetra_people_api.telemetry"): + _run(middleware, _scope()) + + degradation_records = [ + record for record in caplog.records + if getattr(record, "telemetry_event", None) + == "http_server_request_measurement_rejected" + ] + assert len(degradation_records) == 1