chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""_shared — single-source CVDIAG bootstrap shared across all 12 Python
|
||||
integration backends.
|
||||
|
||||
The canonical copy lives at ``showcase/integrations/_shared/``; each Python
|
||||
integration carries a ``_shared`` symlink → ``../_shared`` that the harness
|
||||
build tooling (``stageSharedModules()`` / ``stage_shared()``) dereferences into
|
||||
a real directory inside that integration's Docker build context, landing at
|
||||
``/app/_shared/`` (``/app`` is on PYTHONPATH for all 12). Plan unit: L0-C.
|
||||
"""
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""codegen_cvdiag_schema.py — generate ``cvdiag_schema.py`` (Pydantic v2 models)
|
||||
from the canonical JSON Schema ``showcase/harness/src/cvdiag/schema.json``.
|
||||
|
||||
The Python data-plane models are CODE-GENERATED (never hand-written) so they
|
||||
stay byte-for-byte in lockstep with the cross-language schema that L0-A owns.
|
||||
``schema.json`` is the single intermediate representation consumed here, by the
|
||||
.NET binding (L0-D), the Java binding (L0-E), and the TS binding (L0-F).
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python3 showcase/integrations/_shared/codegen_cvdiag_schema.py # write
|
||||
python3 showcase/integrations/_shared/codegen_cvdiag_schema.py --check # CI drift check
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Path resolution ──────────────────────────────────────────────────────────
|
||||
# This file lives at showcase/integrations/_shared/codegen_cvdiag_schema.py;
|
||||
# the schema lives at showcase/harness/src/cvdiag/schema.json. Resolve both
|
||||
# relative to this file so codegen works from any CWD.
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_REPO_SHOWCASE = _HERE.parent.parent # → showcase/
|
||||
_SCHEMA_JSON = _REPO_SHOWCASE / "harness" / "src" / "cvdiag" / "schema.json"
|
||||
_OUTPUT = _HERE / "cvdiag_schema.py"
|
||||
|
||||
|
||||
def _enum_member_name(value: str) -> str:
|
||||
"""Map a dotted boundary/enum literal to a PascalCase enum member name.
|
||||
|
||||
``probe.dom.firsttoken`` → ``PROBE_DOM_FIRSTTOKEN``; ``ok`` → ``OK``.
|
||||
Uses SCREAMING_SNAKE so the member set is stable + collision-free.
|
||||
"""
|
||||
return value.replace(".", "_").replace("-", "_").upper()
|
||||
|
||||
|
||||
def _metadata_class_name(boundary: str) -> str:
|
||||
"""``probe.dom.firsttoken`` → ``MetadataProbeDomFirsttoken``."""
|
||||
parts = boundary.replace("-", "_").split(".")
|
||||
camel = "".join(p[:1].upper() + p[1:] for p in parts)
|
||||
return f"Metadata{camel}"
|
||||
|
||||
|
||||
def build_module(schema: dict) -> str:
|
||||
"""Render the full ``cvdiag_schema.py`` source from the schema IR."""
|
||||
defs = schema["$defs"]
|
||||
layers: list[str] = defs["layers"]
|
||||
outcomes: list[str] = defs["outcomes"]
|
||||
boundaries: list[str] = defs["boundaries"]
|
||||
edge_keys: list[str] = defs["edge_header_keys"]
|
||||
boundary_meta: dict[str, list[str]] = defs["boundary_metadata_keys"]
|
||||
test_id_pattern: str = schema["properties"]["test_id"]["pattern"]
|
||||
span_id_pattern: str = schema["properties"]["span_id"]["pattern"]
|
||||
slug_pattern: str = schema["properties"]["slug"]["pattern"]
|
||||
schema_version: int = schema["schema_version"]
|
||||
|
||||
out: list[str] = []
|
||||
a = out.append
|
||||
|
||||
a('"""cvdiag_schema.py — GENERATED Pydantic v2 models for the CVDIAG envelope.')
|
||||
a("")
|
||||
a("DO NOT EDIT BY HAND. This file is code-generated from")
|
||||
a("``showcase/harness/src/cvdiag/schema.json`` by")
|
||||
a("``showcase/integrations/_shared/codegen_cvdiag_schema.py``. Re-run that")
|
||||
a("script (and commit the result) whenever the schema changes; CI runs the")
|
||||
a("generator with ``--check`` to fail on drift. Plan unit: L0-C.")
|
||||
a('"""')
|
||||
a("")
|
||||
a("from __future__ import annotations")
|
||||
a("")
|
||||
a("from enum import Enum")
|
||||
a("from typing import Any, Optional")
|
||||
a("")
|
||||
a("from pydantic import BaseModel, ConfigDict, Field, model_validator")
|
||||
a("")
|
||||
a(f"SCHEMA_VERSION = {schema_version}")
|
||||
a("")
|
||||
a("# UUIDv7 (RFC 9562) pattern for ``test_id`` — version nibble 7, variant 10.")
|
||||
a(f"TEST_ID_PATTERN = r{test_id_pattern!r}")
|
||||
a(f"SPAN_ID_PATTERN = r{span_id_pattern!r}")
|
||||
a(f"SLUG_PATTERN = r{slug_pattern!r}")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Enums ────────────────────────────────────────────────────────────────
|
||||
a("class CvdiagLayer(str, Enum):")
|
||||
a(' """Owning layer of a CVDIAG envelope (spec §5)."""')
|
||||
a("")
|
||||
for lyr in layers:
|
||||
a(f' {_enum_member_name(lyr)} = "{lyr}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
a("class CvdiagOutcome(str, Enum):")
|
||||
a(' """Terminal outcome of a boundary observation (spec §5)."""')
|
||||
a("")
|
||||
for oc in outcomes:
|
||||
a(f' {_enum_member_name(oc)} = "{oc}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
a("class CvdiagBoundary(str, Enum):")
|
||||
a(' """The closed set of 29 data-plane + 4 accounting boundaries (spec §5)."""')
|
||||
a("")
|
||||
for b in boundaries:
|
||||
a(f' {_enum_member_name(b)} = "{b}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── EdgeHeaders ────────────────────────────────────────────────────────────
|
||||
a("class EdgeHeaders(BaseModel):")
|
||||
a(' """The closed 9-key edge-header bag (spec §5). Absent → ``None``.')
|
||||
a("")
|
||||
a(" ``model_config`` forbids extra keys so a forbidden/unknown edge header")
|
||||
a(" can never round-trip through this model.")
|
||||
a(' """')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(extra="forbid")')
|
||||
a("")
|
||||
for key in edge_keys:
|
||||
# Header names contain hyphens → use an alias and a sanitized field name.
|
||||
field = key.replace("-", "_")
|
||||
a(f' {field}: Optional[str] = Field(default=None, alias="{key}")')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Per-boundary metadata models (data-plane boundaries only) ──────────────
|
||||
a("# ── Per-boundary metadata models (one per data-plane boundary, spec §5) ──")
|
||||
a("# Each forbids extra keys so an unknown metadata key is surfaced (caller")
|
||||
a("# stamps ``_metadata_dropped`` on the envelope).")
|
||||
a("")
|
||||
metadata_class_map: list[tuple[str, str]] = []
|
||||
for boundary in boundaries:
|
||||
keys = boundary_meta.get(boundary)
|
||||
if keys is None:
|
||||
# Accounting (cvdiag.*) boundaries carry a free-form metadata bag.
|
||||
continue
|
||||
cls = _metadata_class_name(boundary)
|
||||
metadata_class_map.append((boundary, cls))
|
||||
a("")
|
||||
a(f"class {cls}(BaseModel):")
|
||||
a(f' """Metadata for boundary ``{boundary}`` (closed key set)."""')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(extra="forbid")')
|
||||
a("")
|
||||
for k in keys:
|
||||
a(f" {k}: Optional[Any] = None")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# Map from boundary literal → metadata model class (for callers/tests).
|
||||
a("#: boundary literal → its closed metadata model (data-plane only).")
|
||||
a("BOUNDARY_METADATA_MODEL: dict[str, type[BaseModel]] = {")
|
||||
for boundary, cls in metadata_class_map:
|
||||
a(f' "{boundary}": {cls},')
|
||||
a("}")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Envelope ───────────────────────────────────────────────────────────────
|
||||
a("class CvdiagEnvelope(BaseModel):")
|
||||
a(' """The CVDIAG flap-observability envelope (spec §5).')
|
||||
a("")
|
||||
a(" Unknown TOP-LEVEL keys are dropped (closed-world) and the drop is")
|
||||
a(" recorded via ``_metadata_dropped``; the ``metadata`` bag itself is")
|
||||
a(" free-form here (per-boundary closed validation is applied separately")
|
||||
a(" via ``BOUNDARY_METADATA_MODEL`` so a metadata-only unknown key does not")
|
||||
a(" reject the whole envelope, it just stamps ``_metadata_dropped``).")
|
||||
a(' """')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(populate_by_name=True, extra="ignore")')
|
||||
a("")
|
||||
a(f" schema_version: int = SCHEMA_VERSION")
|
||||
a(" test_id: str = Field(pattern=TEST_ID_PATTERN)")
|
||||
a(" trace_id: str")
|
||||
a(" span_id: str = Field(pattern=SPAN_ID_PATTERN)")
|
||||
a(" parent_span_id: Optional[str] = None")
|
||||
a(" layer: CvdiagLayer")
|
||||
a(" boundary: CvdiagBoundary")
|
||||
a(" slug: str = Field(pattern=SLUG_PATTERN)")
|
||||
a(" demo: str")
|
||||
a(" ts: str")
|
||||
a(" mono_ns: int")
|
||||
a(" duration_ms: Optional[int] = None")
|
||||
a(" outcome: CvdiagOutcome")
|
||||
a(" edge_headers: EdgeHeaders")
|
||||
a(" metadata: dict[str, Any] = Field(default_factory=dict)")
|
||||
a(' metadata_dropped: bool = Field(default=False, alias="_metadata_dropped")')
|
||||
a(' truncated: bool = Field(default=False, alias="_truncated")')
|
||||
a("")
|
||||
a(' @model_validator(mode="before")')
|
||||
a(" @classmethod")
|
||||
a(" def _stamp_dropped_unknown_keys(cls, data: Any) -> Any:")
|
||||
a(' """Stamp ``_metadata_dropped`` when unknown top-level OR metadata keys')
|
||||
a(" are present, then strip the unknown top-level keys (closed-world).")
|
||||
a(' """')
|
||||
a(" if not isinstance(data, dict):")
|
||||
a(" return data")
|
||||
a(" known = set(cls.model_fields.keys())")
|
||||
a(" aliases = {")
|
||||
a(" f.alias for f in cls.model_fields.values() if f.alias is not None")
|
||||
a(" }")
|
||||
a(" allowed = known | aliases")
|
||||
a(" dropped = False")
|
||||
a(" cleaned: dict[str, Any] = {}")
|
||||
a(" for key, value in data.items():")
|
||||
a(" if key in allowed:")
|
||||
a(" cleaned[key] = value")
|
||||
a(" else:")
|
||||
a(" dropped = True # unknown top-level key → drop + stamp")
|
||||
a(" # Unknown metadata keys (against the per-boundary closed model).")
|
||||
a(' boundary = cleaned.get("boundary")')
|
||||
a(' meta = cleaned.get("metadata")')
|
||||
a(" if isinstance(boundary, str) and isinstance(meta, dict):")
|
||||
a(" model = BOUNDARY_METADATA_MODEL.get(boundary)")
|
||||
a(" if model is not None:")
|
||||
a(" allowed_meta = set(model.model_fields.keys())")
|
||||
a(" if any(mk not in allowed_meta for mk in meta):")
|
||||
a(" dropped = True")
|
||||
a(" if dropped:")
|
||||
a(' cleaned["_metadata_dropped"] = True')
|
||||
a(" return cleaned")
|
||||
a("")
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
return json.loads(_SCHEMA_JSON.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render() -> str:
|
||||
return build_module(load_schema())
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
rendered = render()
|
||||
if "--check" in argv:
|
||||
try:
|
||||
on_disk = _OUTPUT.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"CVDIAG codegen: cvdiag_schema.py is MISSING — run the generator and commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if on_disk == rendered:
|
||||
print("CVDIAG codegen: cvdiag_schema.py is in sync with schema.json")
|
||||
return 0
|
||||
print(
|
||||
"CVDIAG codegen: cvdiag_schema.py is STALE — run the generator and commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
_OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
print(f"CVDIAG codegen: wrote {_OUTPUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""cvdiag_bootstrap.py — single-source CVDIAG runtime bootstrap for every Python
|
||||
integration backend.
|
||||
|
||||
Importing this module (``import _shared.cvdiag_bootstrap``) at the top of an
|
||||
integration entrypoint does three things, once, at import time:
|
||||
|
||||
1. **Captures the ``agents.*`` loggers** by attaching a SCOPED stream handler
|
||||
to the ``agents`` logger so the ``agents._header_forwarding`` (and sibling
|
||||
``agents.*``) loggers actually EMIT. This fixes the silent-drop bug: those
|
||||
loggers call ``logger.info(...)`` but, with no handler attached anywhere up
|
||||
the hierarchy, the records were being discarded. We attach a dedicated
|
||||
handler to the ``agents`` logger (NOT ``basicConfig(force=True)`` on root)
|
||||
so the CVDIAG lines reach stdout where the harness greps for them WITHOUT
|
||||
tearing down the HOST application's own root-logger configuration — the
|
||||
module is fully inert (no global logging mutation) when cvdiag is disabled,
|
||||
matching the canary-safe contract the TS emitter upholds.
|
||||
|
||||
2. **Resolves the verbosity tier** (default | verbose | debug) and applies the
|
||||
§6 fail-closed guard: ``CVDIAG_DEBUG`` is REFUSED (raises at import time)
|
||||
when the deployment environment resolves to ``production`` or cannot be
|
||||
resolved at all (unknown env is treated as production).
|
||||
|
||||
3. **Exposes ``emit_cvdiag(envelope)``** — validates the envelope against the
|
||||
generated Pydantic model, writes a single ``CVDIAG`` JSON line to stdout,
|
||||
and best-effort hands the row to the threaded PocketBase writer.
|
||||
|
||||
Pure instrumentation: ``emit_cvdiag`` never throws into the caller. The ONE
|
||||
permitted raise is the fail-closed DEBUG guard during ``setup()`` (a startup
|
||||
assertion, mirroring the TS emitter's constructor guard).
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
from _shared.cvdiag_schema import CvdiagEnvelope
|
||||
|
||||
logger = logging.getLogger("agents._cvdiag_bootstrap")
|
||||
|
||||
# ── Tier resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
# Production-detection env precedence (spec §6):
|
||||
# SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → PYTHON_ENV.
|
||||
_ENV_PRECEDENCE = ("SHOWCASE_ENV", "RAILWAY_ENVIRONMENT_NAME", "PYTHON_ENV")
|
||||
|
||||
# Module-level singletons, populated by setup().
|
||||
_TIER: str = "default"
|
||||
_PB_WRITER: Optional[CvdiagPbWriter] = None
|
||||
# Idempotency guard: a successful (or degraded) setup() flips this so any
|
||||
# repeated invocation is a no-op — repeated calls must NOT orphan a second
|
||||
# flush daemon / PB writer queue.
|
||||
_SETUP_DONE = False
|
||||
# True iff cvdiag instrumentation is active. Flipped OFF (fail-closed) when a
|
||||
# misconfiguration is detected so the backend keeps running with instrumentation
|
||||
# disabled rather than crashing at import.
|
||||
_ENABLED = False
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
# The scoped handler we attach to the ``agents`` logger when ENABLED. Tracked so
|
||||
# the capture install is idempotent and ``reset_for_test`` can detach it,
|
||||
# leaving no residual host-logging mutation between tests.
|
||||
_AGENTS_LOG_NAME = "agents"
|
||||
_CAPTURE_HANDLER: Optional[logging.Handler] = None
|
||||
|
||||
|
||||
def _install_agents_log_capture() -> None:
|
||||
"""Attach a scoped stream handler to the ``agents`` logger (idempotent).
|
||||
|
||||
This is the silent-drop fix WITHOUT the global blast radius of
|
||||
``basicConfig(force=True)``: we never touch the root logger's handlers, so
|
||||
the host application's own logging configuration is preserved. The handler
|
||||
is attached only when cvdiag is ENABLED; a disabled / degraded backend
|
||||
leaves host logging byte-for-byte untouched.
|
||||
"""
|
||||
global _CAPTURE_HANDLER
|
||||
if _CAPTURE_HANDLER is not None:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||
agents_logger = logging.getLogger(_AGENTS_LOG_NAME)
|
||||
agents_logger.addHandler(handler)
|
||||
# Ensure ``agents.*`` records at INFO survive the level filter even if the
|
||||
# host left the (effective) level above INFO; scoped to the agents subtree.
|
||||
if agents_logger.level == logging.NOTSET or agents_logger.level > logging.INFO:
|
||||
agents_logger.setLevel(logging.INFO)
|
||||
_CAPTURE_HANDLER = handler
|
||||
|
||||
|
||||
def resolve_env_label(env: Optional[dict[str, str]] = None) -> Optional[str]:
|
||||
"""Resolve the deployment-environment label (lowercased) or ``None``.
|
||||
|
||||
Precedence: ``SHOWCASE_ENV`` → ``RAILWAY_ENVIRONMENT_NAME`` → ``PYTHON_ENV``.
|
||||
"""
|
||||
src = env if env is not None else os.environ
|
||||
for key in _ENV_PRECEDENCE:
|
||||
raw = src.get(key)
|
||||
if raw is not None and raw != "":
|
||||
return str(raw).lower()
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tier(env: dict[str, str]) -> str:
|
||||
"""Resolve the verbosity tier, applying the §6 fail-closed DEBUG guard.
|
||||
|
||||
Raises ``RuntimeError`` (fail-closed) when DEBUG is requested but the
|
||||
deployment environment is ``production`` or unresolved.
|
||||
"""
|
||||
wants_debug = env.get("CVDIAG_DEBUG") == "1"
|
||||
wants_verbose = env.get("CVDIAG_VERBOSE") == "1"
|
||||
if wants_debug:
|
||||
label = resolve_env_label(env)
|
||||
if label is None:
|
||||
raise RuntimeError(
|
||||
"CVDIAG_DEBUG refused: deployment environment is unresolved "
|
||||
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → PYTHON_ENV all "
|
||||
"unset); fail-closed treats unknown env as production."
|
||||
)
|
||||
if label == "production":
|
||||
raise RuntimeError(
|
||||
"CVDIAG_DEBUG refused: deployment environment is production."
|
||||
)
|
||||
return "debug"
|
||||
if wants_verbose:
|
||||
return "verbose"
|
||||
return "default"
|
||||
|
||||
|
||||
def setup(env: Optional[dict[str, str]] = None) -> None:
|
||||
"""Idempotent bootstrap: resolve tier, build the PB writer, capture agents logs.
|
||||
|
||||
Runs once at import time. Three safety contracts:
|
||||
|
||||
* **Idempotent** — a second invocation after a completed setup() is a
|
||||
no-op (the ``_SETUP_DONE`` guard); repeated calls must never orphan a
|
||||
second flush daemon / PB writer queue.
|
||||
* **Inert when disabled** — a disabled / degraded setup() performs NO
|
||||
logging mutation: the scoped ``agents`` capture handler is installed
|
||||
only on the ENABLED path, and the root logger is never touched. Merely
|
||||
importing this module when cvdiag is off leaves the host application's
|
||||
logging configuration byte-for-byte intact (canary-safe).
|
||||
* **Degrade-not-crash** — a misconfiguration (e.g. the §6 fail-closed
|
||||
DEBUG guard) DISABLES cvdiag instrumentation and logs a warning; it
|
||||
must NEVER propagate and abort the host backend's module import. The
|
||||
fail-closed *intent* is preserved (instrumentation stays OFF on a
|
||||
forbidden DEBUG request) but the backend keeps running. This mirrors
|
||||
the TS emitter: it throws at construction, but the wrapper catches it
|
||||
so the host app survives.
|
||||
"""
|
||||
global _TIER, _PB_WRITER, _SETUP_DONE, _ENABLED
|
||||
|
||||
# (0) Idempotency guard — repeated setup() is a no-op (FIX-3).
|
||||
if _SETUP_DONE:
|
||||
return
|
||||
|
||||
src = env if env is not None else dict(os.environ)
|
||||
|
||||
# (1) Resolve tier. ``_resolve_tier`` raises (fail-closed) on a forbidden
|
||||
# DEBUG request — catch it here so a misconfig DEGRADES (instrumentation
|
||||
# OFF) rather than crashing the backend import (FIX-2).
|
||||
try:
|
||||
_TIER = _resolve_tier(src)
|
||||
except RuntimeError as err:
|
||||
_TIER = "default"
|
||||
_ENABLED = False
|
||||
_PB_WRITER = None
|
||||
_SETUP_DONE = True
|
||||
logger.warning(
|
||||
"CVDIAG bootstrap degraded component=_shared reason=%s "
|
||||
"(instrumentation disabled; backend continues)",
|
||||
err,
|
||||
)
|
||||
return
|
||||
|
||||
# (2) Build the threaded PB writer (no-op when CVDIAG_PB_URL unset).
|
||||
_PB_WRITER = CvdiagPbWriter(
|
||||
pb_url=src.get("CVDIAG_PB_URL"),
|
||||
writer_key=src.get("CVDIAG_WRITER_KEY"),
|
||||
)
|
||||
|
||||
_ENABLED = True
|
||||
_SETUP_DONE = True
|
||||
|
||||
# (3) Only NOW — once instrumentation is confirmed ENABLED — install the
|
||||
# scoped ``agents`` logger capture. A disabled / degraded setup (the early
|
||||
# returns above) reaches neither this nor any other logging mutation, so
|
||||
# importing the bootstrap is fully inert when cvdiag is disabled — it never
|
||||
# touches the host application's root-logger handlers.
|
||||
_install_agents_log_capture()
|
||||
logger.info(
|
||||
"CVDIAG bootstrap component=_shared tier=%s pb_enabled=%s",
|
||||
_TIER,
|
||||
str(_PB_WRITER.enabled).lower(),
|
||||
)
|
||||
|
||||
|
||||
def current_tier() -> str:
|
||||
"""Return the resolved tier (``default`` | ``verbose`` | ``debug``)."""
|
||||
return _TIER
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True iff cvdiag instrumentation is active (False after a degraded setup)."""
|
||||
return _ENABLED
|
||||
|
||||
|
||||
def reset_for_test() -> None:
|
||||
"""Reset module state so a test can re-run ``setup()`` from scratch.
|
||||
|
||||
Test-only helper: clears the idempotency guard and singletons. The flush
|
||||
daemon is a short-lived best-effort daemon thread, so we simply drop the
|
||||
reference (the thread exits with the process); we do not join it.
|
||||
|
||||
Also detaches the scoped ``agents`` capture handler so each test starts from
|
||||
an unmutated logging tree (otherwise an enabled setup() would leave a
|
||||
handler attached across tests).
|
||||
"""
|
||||
global _TIER, _PB_WRITER, _SETUP_DONE, _ENABLED, _CAPTURE_HANDLER
|
||||
_TIER = "default"
|
||||
_PB_WRITER = None
|
||||
_SETUP_DONE = False
|
||||
_ENABLED = False
|
||||
if _CAPTURE_HANDLER is not None:
|
||||
logging.getLogger(_AGENTS_LOG_NAME).removeHandler(_CAPTURE_HANDLER)
|
||||
_CAPTURE_HANDLER = None
|
||||
|
||||
|
||||
def emit_cvdiag(envelope: Union[CvdiagEnvelope, dict[str, Any]]) -> None:
|
||||
"""Emit one CVDIAG envelope: validate → JSON line to stdout → best-effort PB.
|
||||
|
||||
Pure instrumentation — catches every error and degrades to a single
|
||||
``CVDIAG emit-failed`` log line; never raises into the caller.
|
||||
|
||||
The shared emit gate is the single chokepoint every integration's backend
|
||||
emitter routes through. It honors the ``_ENABLED`` flag (``is_enabled()``)
|
||||
so a DEGRADED setup() (the §6 fail-closed DEBUG misconfig) actually
|
||||
SUPPRESSES emission — the degrade must win over a live
|
||||
``CVDIAG_BACKEND_EMITTER=1`` toggle, otherwise the fail-closed intent is
|
||||
silently defeated and a degraded backend keeps writing envelopes.
|
||||
"""
|
||||
# Degrade gate: a disabled (degraded) backend emits nothing, regardless of
|
||||
# the per-integration CVDIAG_BACKEND_EMITTER toggle.
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
model = (
|
||||
envelope
|
||||
if isinstance(envelope, CvdiagEnvelope)
|
||||
else CvdiagEnvelope.model_validate(envelope)
|
||||
)
|
||||
payload = model.model_dump(by_alias=True, exclude_none=False)
|
||||
# One JSON line to stdout, ``CVDIAG`` tagged so the harness greps it.
|
||||
sys.stdout.write("CVDIAG " + _dump_json(payload) + "\n")
|
||||
sys.stdout.flush()
|
||||
if _PB_WRITER is not None:
|
||||
_PB_WRITER.enqueue(payload)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must not throw
|
||||
logger.warning("CVDIAG emit-failed error=%s", err)
|
||||
|
||||
|
||||
def _dump_json(payload: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
# Run the bootstrap at import time (the whole point — importing this module
|
||||
# wires logging + tier + PB writer for the integration entrypoint).
|
||||
setup()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""cvdiag_pb_writer.py — best-effort, background (threaded) PocketBase flush for
|
||||
CVDIAG envelopes emitted from the Python integration backends.
|
||||
|
||||
Contract (spec §7 — pure instrumentation, never blocks the observed boundary):
|
||||
- ``enqueue(envelope)`` returns immediately; it only appends to an in-memory
|
||||
queue. A single daemon worker thread drains the queue on a ≤1s window and
|
||||
POSTs to the PocketBase ``cvdiag_events`` collection (CREATE-only).
|
||||
- A PB write failure is swallowed and logged once as
|
||||
``CVDIAG pb-write-failed`` — it must NEVER propagate into the caller.
|
||||
- When ``CVDIAG_PB_URL`` is unset the writer is a no-op sink (enqueue still
|
||||
returns immediately; nothing is flushed). This keeps local/unit runs free
|
||||
of network side effects.
|
||||
|
||||
Authentication (see the 1779990200_create_cvdiag_events.js migration):
|
||||
The ``cvdiag_events`` createRule requires the caller to authenticate as a
|
||||
``cvdiag_api_keys`` auth record whose ``role`` is ``"writer"`` —
|
||||
|
||||
@request.auth.collectionName = "cvdiag_api_keys" && @request.auth.role = "writer"
|
||||
|
||||
PocketBase has NO notion of a bespoke header, so a header-only request is
|
||||
UNAUTHENTICATED and the CREATE 4xxs (the createRule evaluates false). The
|
||||
writer therefore POSTs ``/api/collections/cvdiag_api_keys/auth-with-password``
|
||||
with the fixed writer identity (``cvdiag-writer@keys.local`` — overridable via
|
||||
``CVDIAG_WRITER_IDENTITY``) and ``CVDIAG_WRITER_KEY`` as the PASSWORD, caches
|
||||
the returned token, and sends ``Authorization: Bearer <token>`` on the CREATE.
|
||||
A 401 (token expiry / bad creds) clears the cached token and triggers a single
|
||||
re-auth + retry. Auth failure stays best-effort: it degrades to a no-op + the
|
||||
one-shot ``CVDIAG pb-write-failed`` warn — it NEVER crashes the daemon.
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("agents._cvdiag_pb_writer")
|
||||
|
||||
# Flush window: drain at least this often (spec §7 R5-F12: ≤1s window).
|
||||
FLUSH_WINDOW_S = 1.0
|
||||
# Bounded queue — drop-oldest on overflow so a stuck flush can't grow unbounded.
|
||||
QUEUE_CAP = 5000
|
||||
# Per-flush HTTP timeout so a hung PB never wedges the worker thread.
|
||||
HTTP_TIMEOUT_S = 5.0
|
||||
# Auth collection + fixed default identity of the seeded writer record. The
|
||||
# migration seeds email ``cvdiag-writer@keys.local`` with role ``writer``;
|
||||
# CVDIAG_WRITER_KEY is that record's PASSWORD. The identity is overridable for
|
||||
# environments that rotate the writer email, but defaults to the seeded value.
|
||||
WRITER_AUTH_COLLECTION = "cvdiag_api_keys"
|
||||
DEFAULT_WRITER_IDENTITY = "cvdiag-writer@keys.local"
|
||||
|
||||
|
||||
class CvdiagPbWriter:
|
||||
"""Threaded, best-effort PocketBase writer. Construct once at import time.
|
||||
|
||||
The worker thread is a daemon so it never keeps the process alive on exit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pb_url: Optional[str] = None,
|
||||
writer_key: Optional[str] = None,
|
||||
*,
|
||||
writer_identity: Optional[str] = None,
|
||||
flush_window_s: float = FLUSH_WINDOW_S,
|
||||
) -> None:
|
||||
self._pb_url = pb_url if pb_url is not None else os.environ.get("CVDIAG_PB_URL")
|
||||
self._writer_key = (
|
||||
writer_key
|
||||
if writer_key is not None
|
||||
else os.environ.get("CVDIAG_WRITER_KEY")
|
||||
)
|
||||
self._writer_identity = (
|
||||
writer_identity
|
||||
if writer_identity is not None
|
||||
else os.environ.get("CVDIAG_WRITER_IDENTITY", DEFAULT_WRITER_IDENTITY)
|
||||
)
|
||||
self._flush_window_s = flush_window_s
|
||||
self._queue: "queue.Queue[dict[str, Any]]" = queue.Queue(maxsize=QUEUE_CAP)
|
||||
self._logged_failure = False
|
||||
self._started = False
|
||||
self._lock = threading.Lock()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
# Cached auth-with-password token. Only the single daemon worker thread
|
||||
# touches this (auth + CREATE both run inside ``_run``), so no lock is
|
||||
# needed. ``None`` means "not authenticated yet / cleared after a 401".
|
||||
self._auth_token: Optional[str] = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True iff a PB target URL is configured (otherwise this is a no-op)."""
|
||||
return bool(self._pb_url)
|
||||
|
||||
def _ensure_worker(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
self._worker = threading.Thread(
|
||||
target=self._run,
|
||||
name="cvdiag-pb-writer",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
self._started = True
|
||||
|
||||
def enqueue(self, envelope: dict[str, Any]) -> None:
|
||||
"""Queue one envelope for background flush. Never blocks; never raises.
|
||||
|
||||
On a full queue we drop the OLDEST entry (instrumentation must shed
|
||||
load rather than block the boundary it observes).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
try:
|
||||
self._ensure_worker()
|
||||
try:
|
||||
self._queue.put_nowait(envelope)
|
||||
except queue.Full:
|
||||
# Drop-oldest, then retry once. Best-effort; never block.
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(envelope)
|
||||
except queue.Full:
|
||||
pass
|
||||
except Exception as err: # pragma: no cover - defensive belt
|
||||
self._log_failure(err)
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
envelope = self._queue.get(timeout=self._flush_window_s)
|
||||
except queue.Empty:
|
||||
continue
|
||||
batch = [envelope]
|
||||
# Coalesce anything else already queued into this flush.
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
for env in batch:
|
||||
# Never-propagate: isolate each record so no single envelope
|
||||
# can unwind ``_run`` and PERMANENTLY kill the flush daemon.
|
||||
try:
|
||||
self._post(env)
|
||||
except Exception as err: # noqa: BLE001 - daemon must survive
|
||||
self._log_failure(err)
|
||||
|
||||
def _authenticate(self) -> Optional[str]:
|
||||
"""Auth-with-password as the writer role; return + cache the token.
|
||||
|
||||
Returns the cached token if present, else POSTs the writer identity +
|
||||
``CVDIAG_WRITER_KEY`` (the writer record PASSWORD) to the
|
||||
``cvdiag_api_keys`` auth-with-password endpoint and caches the token.
|
||||
Returns ``None`` on any failure (bad creds, unreachable PB, malformed
|
||||
response) — the caller degrades to a no-op. NEVER raises.
|
||||
"""
|
||||
if self._auth_token:
|
||||
return self._auth_token
|
||||
url = self._pb_url
|
||||
if not url or not self._writer_key:
|
||||
return None
|
||||
endpoint = (
|
||||
url.rstrip("/")
|
||||
+ f"/api/collections/{WRITER_AUTH_COLLECTION}/auth-with-password"
|
||||
)
|
||||
body = json.dumps(
|
||||
{"identity": self._writer_identity, "password": self._writer_key}
|
||||
).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("token")
|
||||
if not token:
|
||||
return None
|
||||
self._auth_token = token
|
||||
return token
|
||||
|
||||
def _post(self, envelope: dict[str, Any]) -> None:
|
||||
url = self._pb_url
|
||||
if not url:
|
||||
return
|
||||
endpoint = url.rstrip("/") + "/api/collections/cvdiag_events/records"
|
||||
# Never-propagate: a single bad record (e.g. a non-JSON-serializable
|
||||
# envelope that makes ``json.dumps`` raise ``TypeError``) or an auth
|
||||
# failure must be logged/dropped, NOT allowed to escape and kill the
|
||||
# drain daemon. This mirrors the TS pb-writer ``writeBatch`` contract —
|
||||
# one bad row / a failed auth degrades to a warn; the worker survives.
|
||||
try:
|
||||
body = json.dumps(envelope).encode("utf-8")
|
||||
# Authenticate as the writer-role record (createRule requires it).
|
||||
# On a 401 (token expiry / stale token) clear the cache and re-auth
|
||||
# once before giving up — but never loop.
|
||||
self._create_with_auth(endpoint, body, allow_reauth=True)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must never throw
|
||||
self._log_failure(err)
|
||||
|
||||
def _create_with_auth(
|
||||
self, endpoint: str, body: bytes, *, allow_reauth: bool
|
||||
) -> None:
|
||||
"""POST the CREATE with a Bearer token; re-auth once on a 401."""
|
||||
token = self._authenticate()
|
||||
if not token:
|
||||
# Auth failed (bad/missing writer key, unreachable PB). Degrade to a
|
||||
# no-op + the one-shot warn — never crash the daemon.
|
||||
self._log_failure(RuntimeError("CVDIAG writer auth failed"))
|
||||
return
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S).close()
|
||||
except urllib.error.HTTPError as err:
|
||||
# 401 → token expired / revoked. Clear the cache and re-auth ONCE.
|
||||
if err.code == 401 and allow_reauth:
|
||||
self._auth_token = None
|
||||
self._create_with_auth(endpoint, body, allow_reauth=False)
|
||||
return
|
||||
raise
|
||||
|
||||
def _log_failure(self, err: Exception) -> None:
|
||||
# Log the first failure at WARNING; subsequent ones at DEBUG to avoid
|
||||
# spamming the log on a sustained PB outage.
|
||||
if not self._logged_failure:
|
||||
self._logged_failure = True
|
||||
logger.warning("CVDIAG pb-write-failed error=%s", err)
|
||||
else:
|
||||
logger.debug("CVDIAG pb-write-failed error=%s", err)
|
||||
@@ -0,0 +1,482 @@
|
||||
"""cvdiag_schema.py — GENERATED Pydantic v2 models for the CVDIAG envelope.
|
||||
|
||||
DO NOT EDIT BY HAND. This file is code-generated from
|
||||
``showcase/harness/src/cvdiag/schema.json`` by
|
||||
``showcase/integrations/_shared/codegen_cvdiag_schema.py``. Re-run that
|
||||
script (and commit the result) whenever the schema changes; CI runs the
|
||||
generator with ``--check`` to fail on drift. Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# UUIDv7 (RFC 9562) pattern for ``test_id`` — version nibble 7, variant 10.
|
||||
TEST_ID_PATTERN = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
SPAN_ID_PATTERN = r"^[0-9a-f]{16}$"
|
||||
SLUG_PATTERN = r"^[a-z][a-z0-9-]{0,63}$"
|
||||
|
||||
|
||||
class CvdiagLayer(str, Enum):
|
||||
"""Owning layer of a CVDIAG envelope (spec §5)."""
|
||||
|
||||
PROBE = "probe"
|
||||
BACKEND = "backend"
|
||||
AIMOCK = "aimock"
|
||||
|
||||
|
||||
class CvdiagOutcome(str, Enum):
|
||||
"""Terminal outcome of a boundary observation (spec §5)."""
|
||||
|
||||
OK = "ok"
|
||||
ERR = "err"
|
||||
TIMEOUT = "timeout"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class CvdiagBoundary(str, Enum):
|
||||
"""The closed set of 29 data-plane + 4 accounting boundaries (spec §5)."""
|
||||
|
||||
PROBE_START = "probe.start"
|
||||
PROBE_NAVIGATE_COMPLETE = "probe.navigate.complete"
|
||||
PROBE_MESSAGE_SEND = "probe.message.send"
|
||||
PROBE_DOM_CONTAINER_MOUNT = "probe.dom.container.mount"
|
||||
PROBE_DOM_FIRSTTOKEN = "probe.dom.firsttoken"
|
||||
PROBE_DOM_ALTERNATE_CONTENT = "probe.dom.alternate_content"
|
||||
PROBE_SSE_EVENT = "probe.sse.event"
|
||||
PROBE_SSE_ABORTED = "probe.sse.aborted"
|
||||
PROBE_NETWORK_ERROR = "probe.network.error"
|
||||
PROBE_NETWORK_RESPONSE = "probe.network.response"
|
||||
PROBE_CONSOLE_ERROR = "probe.console.error"
|
||||
PROBE_EXIT = "probe.exit"
|
||||
BACKEND_REQUEST_INGRESS = "backend.request.ingress"
|
||||
BACKEND_AGENT_ENTER = "backend.agent.enter"
|
||||
BACKEND_LLM_CALL_START = "backend.llm.call.start"
|
||||
BACKEND_LLM_CALL_HEARTBEAT = "backend.llm.call.heartbeat"
|
||||
BACKEND_LLM_CALL_RESPONSE = "backend.llm.call.response"
|
||||
BACKEND_SSE_FIRST_BYTE = "backend.sse.first_byte"
|
||||
BACKEND_SSE_EVENT = "backend.sse.event"
|
||||
BACKEND_SSE_ABORTED = "backend.sse.aborted"
|
||||
BACKEND_AGENT_EXIT = "backend.agent.exit"
|
||||
BACKEND_RESPONSE_COMPLETE = "backend.response.complete"
|
||||
BACKEND_ERROR_CAUGHT = "backend.error.caught"
|
||||
AIMOCK_REQUEST_INGRESS = "aimock.request.ingress"
|
||||
AIMOCK_MATCH_DECISION = "aimock.match.decision"
|
||||
AIMOCK_RESPONSE_START = "aimock.response.start"
|
||||
AIMOCK_SSE_CHUNK = "aimock.sse.chunk"
|
||||
AIMOCK_RESPONSE_ABORTED = "aimock.response.aborted"
|
||||
AIMOCK_RESPONSE_COMPLETE = "aimock.response.complete"
|
||||
CVDIAG_PURGE_AUDIT = "cvdiag.purge_audit"
|
||||
CVDIAG_COLLISION_DETECTED = "cvdiag.collision_detected"
|
||||
CVDIAG_QUEUE_DROPPED = "cvdiag.queue_dropped"
|
||||
CVDIAG_METADATA_DROPPED = "cvdiag.metadata_dropped"
|
||||
|
||||
|
||||
class EdgeHeaders(BaseModel):
|
||||
"""The closed 9-key edge-header bag (spec §5). Absent → ``None``.
|
||||
|
||||
``model_config`` forbids extra keys so a forbidden/unknown edge header
|
||||
can never round-trip through this model.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
cf_ray: Optional[str] = Field(default=None, alias="cf-ray")
|
||||
cf_mitigated: Optional[str] = Field(default=None, alias="cf-mitigated")
|
||||
cf_cache_status: Optional[str] = Field(default=None, alias="cf-cache-status")
|
||||
x_railway_edge: Optional[str] = Field(default=None, alias="x-railway-edge")
|
||||
x_railway_request_id: Optional[str] = Field(
|
||||
default=None, alias="x-railway-request-id"
|
||||
)
|
||||
x_hikari_trace: Optional[str] = Field(default=None, alias="x-hikari-trace")
|
||||
retry_after: Optional[str] = Field(default=None, alias="retry-after")
|
||||
via: Optional[str] = Field(default=None, alias="via")
|
||||
server: Optional[str] = Field(default=None, alias="server")
|
||||
|
||||
|
||||
# ── Per-boundary metadata models (one per data-plane boundary, spec §5) ──
|
||||
# Each forbids extra keys so an unknown metadata key is surfaced (caller
|
||||
# stamps ``_metadata_dropped`` on the envelope).
|
||||
|
||||
|
||||
class MetadataProbeStart(BaseModel):
|
||||
"""Metadata for boundary ``probe.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
viewport: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNavigateComplete(BaseModel):
|
||||
"""Metadata for boundary ``probe.navigate.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
nav_ms: Optional[Any] = None
|
||||
http_status: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeMessageSend(BaseModel):
|
||||
"""Metadata for boundary ``probe.message.send`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
message_index: Optional[Any] = None
|
||||
char_count: Optional[Any] = None
|
||||
demo: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomContainerMount(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.container.mount`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_start: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomFirsttoken(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.firsttoken`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_start: Optional[Any] = None
|
||||
text_length: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomAlternate_content(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.alternate_content`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
child_type_histogram: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeSseEvent(BaseModel):
|
||||
"""Metadata for boundary ``probe.sse.event`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: Optional[Any] = None
|
||||
payload_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeSseAborted(BaseModel):
|
||||
"""Metadata for boundary ``probe.sse.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNetworkError(BaseModel):
|
||||
"""Metadata for boundary ``probe.network.error`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
error_class: Optional[Any] = None
|
||||
response_status: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNetworkResponse(BaseModel):
|
||||
"""Metadata for boundary ``probe.network.response`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
status: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
duration_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeConsoleError(BaseModel):
|
||||
"""Metadata for boundary ``probe.console.error`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
level: Optional[Any] = None
|
||||
message_scrubbed: Optional[Any] = None
|
||||
source_file: Optional[Any] = None
|
||||
line_col: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeExit(BaseModel):
|
||||
"""Metadata for boundary ``probe.exit`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
terminal_outcome: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
sse_event_count: Optional[Any] = None
|
||||
first_token_delta_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendRequestIngress(BaseModel):
|
||||
"""Metadata for boundary ``backend.request.ingress`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
method: Optional[Any] = None
|
||||
path: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendAgentEnter(BaseModel):
|
||||
"""Metadata for boundary ``backend.agent.enter`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_name: Optional[Any] = None
|
||||
model_id: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallStart(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: Optional[Any] = None
|
||||
model: Optional[Any] = None
|
||||
prompt_token_count_estimate: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallHeartbeat(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.heartbeat`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
elapsed_ms_since_start: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallResponse(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.response`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: Optional[Any] = None
|
||||
model: Optional[Any] = None
|
||||
response_token_count: Optional[Any] = None
|
||||
latency_ms: Optional[Any] = None
|
||||
error_class: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseFirst_byte(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.first_byte`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_ingress: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseEvent(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.event`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: Optional[Any] = None
|
||||
payload_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseAborted(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendAgentExit(BaseModel):
|
||||
"""Metadata for boundary ``backend.agent.exit`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
terminal_outcome: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendResponseComplete(BaseModel):
|
||||
"""Metadata for boundary ``backend.response.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
http_status: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
sse_event_count: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendErrorCaught(BaseModel):
|
||||
"""Metadata for boundary ``backend.error.caught`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
exception_type: Optional[Any] = None
|
||||
message_scrubbed: Optional[Any] = None
|
||||
stack_brief: Optional[Any] = None
|
||||
truncated: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockRequestIngress(BaseModel):
|
||||
"""Metadata for boundary ``aimock.request.ingress`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
match_keys: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockMatchDecision(BaseModel):
|
||||
"""Metadata for boundary ``aimock.match.decision`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
fixture_id: Optional[Any] = None
|
||||
match_score: Optional[Any] = None
|
||||
reject_reasons: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseStart(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_ingress: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockSseChunk(BaseModel):
|
||||
"""Metadata for boundary ``aimock.sse.chunk`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chunk_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseAborted(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseComplete(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
http_status: Optional[Any] = None
|
||||
total_bytes: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
chunk_count: Optional[Any] = None
|
||||
|
||||
|
||||
#: boundary literal → its closed metadata model (data-plane only).
|
||||
BOUNDARY_METADATA_MODEL: dict[str, type[BaseModel]] = {
|
||||
"probe.start": MetadataProbeStart,
|
||||
"probe.navigate.complete": MetadataProbeNavigateComplete,
|
||||
"probe.message.send": MetadataProbeMessageSend,
|
||||
"probe.dom.container.mount": MetadataProbeDomContainerMount,
|
||||
"probe.dom.firsttoken": MetadataProbeDomFirsttoken,
|
||||
"probe.dom.alternate_content": MetadataProbeDomAlternate_content,
|
||||
"probe.sse.event": MetadataProbeSseEvent,
|
||||
"probe.sse.aborted": MetadataProbeSseAborted,
|
||||
"probe.network.error": MetadataProbeNetworkError,
|
||||
"probe.network.response": MetadataProbeNetworkResponse,
|
||||
"probe.console.error": MetadataProbeConsoleError,
|
||||
"probe.exit": MetadataProbeExit,
|
||||
"backend.request.ingress": MetadataBackendRequestIngress,
|
||||
"backend.agent.enter": MetadataBackendAgentEnter,
|
||||
"backend.llm.call.start": MetadataBackendLlmCallStart,
|
||||
"backend.llm.call.heartbeat": MetadataBackendLlmCallHeartbeat,
|
||||
"backend.llm.call.response": MetadataBackendLlmCallResponse,
|
||||
"backend.sse.first_byte": MetadataBackendSseFirst_byte,
|
||||
"backend.sse.event": MetadataBackendSseEvent,
|
||||
"backend.sse.aborted": MetadataBackendSseAborted,
|
||||
"backend.agent.exit": MetadataBackendAgentExit,
|
||||
"backend.response.complete": MetadataBackendResponseComplete,
|
||||
"backend.error.caught": MetadataBackendErrorCaught,
|
||||
"aimock.request.ingress": MetadataAimockRequestIngress,
|
||||
"aimock.match.decision": MetadataAimockMatchDecision,
|
||||
"aimock.response.start": MetadataAimockResponseStart,
|
||||
"aimock.sse.chunk": MetadataAimockSseChunk,
|
||||
"aimock.response.aborted": MetadataAimockResponseAborted,
|
||||
"aimock.response.complete": MetadataAimockResponseComplete,
|
||||
}
|
||||
|
||||
|
||||
class CvdiagEnvelope(BaseModel):
|
||||
"""The CVDIAG flap-observability envelope (spec §5).
|
||||
|
||||
Unknown TOP-LEVEL keys are dropped (closed-world) and the drop is
|
||||
recorded via ``_metadata_dropped``; the ``metadata`` bag itself is
|
||||
free-form here (per-boundary closed validation is applied separately
|
||||
via ``BOUNDARY_METADATA_MODEL`` so a metadata-only unknown key does not
|
||||
reject the whole envelope, it just stamps ``_metadata_dropped``).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
test_id: str = Field(pattern=TEST_ID_PATTERN)
|
||||
trace_id: str
|
||||
span_id: str = Field(pattern=SPAN_ID_PATTERN)
|
||||
parent_span_id: Optional[str] = None
|
||||
layer: CvdiagLayer
|
||||
boundary: CvdiagBoundary
|
||||
slug: str = Field(pattern=SLUG_PATTERN)
|
||||
demo: str
|
||||
ts: str
|
||||
mono_ns: int
|
||||
duration_ms: Optional[int] = None
|
||||
outcome: CvdiagOutcome
|
||||
edge_headers: EdgeHeaders
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
metadata_dropped: bool = Field(default=False, alias="_metadata_dropped")
|
||||
truncated: bool = Field(default=False, alias="_truncated")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _stamp_dropped_unknown_keys(cls, data: Any) -> Any:
|
||||
"""Stamp ``_metadata_dropped`` when unknown top-level OR metadata keys
|
||||
are present, then strip the unknown top-level keys (closed-world).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
known = set(cls.model_fields.keys())
|
||||
aliases = {f.alias for f in cls.model_fields.values() if f.alias is not None}
|
||||
allowed = known | aliases
|
||||
dropped = False
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in allowed:
|
||||
cleaned[key] = value
|
||||
else:
|
||||
dropped = True # unknown top-level key → drop + stamp
|
||||
# Unknown metadata keys (against the per-boundary closed model).
|
||||
boundary = cleaned.get("boundary")
|
||||
meta = cleaned.get("metadata")
|
||||
if isinstance(boundary, str) and isinstance(meta, dict):
|
||||
model = BOUNDARY_METADATA_MODEL.get(boundary)
|
||||
if model is not None:
|
||||
allowed_meta = set(model.model_fields.keys())
|
||||
if any(mk not in allowed_meta for mk in meta):
|
||||
dropped = True
|
||||
if dropped:
|
||||
cleaned["_metadata_dropped"] = True
|
||||
return cleaned
|
||||
@@ -0,0 +1,483 @@
|
||||
// CvdiagEmitter.cs — the shared .NET CVDIAG emitter (plan unit L0-D; spec §6/§7).
|
||||
//
|
||||
// Mirrors the canonical TS `emit.ts` contract for the .NET integrations:
|
||||
// • Tier resolution from the deployment environment, with the .NET-specific
|
||||
// precedence SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT
|
||||
// (the last is the .NET analogue of TS's NODE_ENV).
|
||||
// • Fail-closed DEBUG: the constructor throws (startup assertion — the ONE
|
||||
// place the emitter is permitted to throw) when DEBUG is requested in a
|
||||
// production env, when no env resolves (unknown == production), or when no
|
||||
// CVDIAG_DEBUG_ALLOW_LIST is provided.
|
||||
// • §6 tier matrix: a boundary is emitted only if included at the current
|
||||
// tier; `cvdiag.*` accounting boundaries are always emitted.
|
||||
// • DEBUG auto-off: after 10 minutes OR 10k events, DEBUG disarms and falls
|
||||
// back to default-tier inclusion.
|
||||
// • Closed-world metadata filter: unknown metadata keys for the boundary are
|
||||
// dropped and `_metadata_dropped` is stamped.
|
||||
// • Edge-header allow/deny filter (9 allow / 12 deny, exact-match, deny wins,
|
||||
// case-insensitive, no cf-ip* wildcard).
|
||||
// • Per-event byte cap by tier; over-budget envelopes are trimmed +
|
||||
// `_truncated` stamped.
|
||||
// • EmitEvent → single-line JSON to stdout, PLUS a background fire-and-forget
|
||||
// PocketBase write (HttpClient, ≤1s timeout) that never blocks or throws
|
||||
// into the observed boundary.
|
||||
//
|
||||
// Pure instrumentation: outside the constructor's startup guard, a CVDIAG
|
||||
// failure must NEVER throw into the caller (spec §7). All hot-path errors
|
||||
// degrade to a stderr CVDIAG-tagged warning.
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Resolved verbosity tier (cumulative; spec §6).</summary>
|
||||
public enum CvdiagTier { Default, Verbose, Debug }
|
||||
|
||||
/// <summary>Construction options for <see cref="CvdiagEmitter"/>.</summary>
|
||||
public sealed class CvdiagEmitterOptions
|
||||
{
|
||||
/// <summary>Force DEBUG tier (subject to the fail-closed prod guard).</summary>
|
||||
public bool Debug { get; init; }
|
||||
/// <summary>Force VERBOSE tier (DEBUG wins if both set).</summary>
|
||||
public bool Verbose { get; init; }
|
||||
/// <summary>Environment bag; defaults to the process environment.</summary>
|
||||
public IReadOnlyDictionary<string, string?>? Env { get; init; }
|
||||
/// <summary>Owning layer for default envelope fields.</summary>
|
||||
public CvdiagLayer Layer { get; init; } = CvdiagLayer.Backend;
|
||||
/// <summary>PocketBase ingest URL; when null, no background write is attempted.</summary>
|
||||
public string? PbWriteUrl { get; init; }
|
||||
/// <summary>Injected HttpClient (tests); defaults to a shared 1s-timeout client.</summary>
|
||||
public HttpClient? HttpClient { get; init; }
|
||||
/// <summary>Emit the single-line JSON to stdout (default true).</summary>
|
||||
public bool WriteToStdout { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Arguments for a single <see cref="CvdiagEmitter.Emit"/> call.</summary>
|
||||
public sealed class CvdiagEmitArgs
|
||||
{
|
||||
public required CvdiagLayer Layer { get; init; }
|
||||
public required CvdiagBoundary Boundary { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required string Demo { get; init; }
|
||||
public required CvdiagOutcome Outcome { get; init; }
|
||||
public EdgeHeaders? EdgeHeaders { get; init; }
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
public long? DurationMs { get; init; }
|
||||
public string? ParentSpanId { get; init; }
|
||||
/// <summary>Override test_id (e.g. probe.start mints one and threads it).</summary>
|
||||
public string? TestId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CvdiagEmitter
|
||||
{
|
||||
// §6 hard bounds + §7 byte caps (mirror emit.ts constants).
|
||||
private const long DebugMaxWallclockMs = 10 * 60 * 1000;
|
||||
private const int DebugMaxEvents = 10_000;
|
||||
private static readonly IReadOnlyDictionary<CvdiagTier, int> ByteCapByTier =
|
||||
new Dictionary<CvdiagTier, int>
|
||||
{
|
||||
[CvdiagTier.Default] = 2 * 1024,
|
||||
[CvdiagTier.Verbose] = 4 * 1024,
|
||||
[CvdiagTier.Debug] = 16 * 1024,
|
||||
};
|
||||
|
||||
private const string AccountingPrefix = "cvdiag.";
|
||||
|
||||
// The 12-name DENY list (spec §5). Exact-match, lowercase, deny wins; the
|
||||
// cf-ip* family is blocked by these explicit entries, NOT a wildcard.
|
||||
private static readonly HashSet<string> DenyList = new(StringComparer.Ordinal)
|
||||
{
|
||||
"cf-ipcountry", "cf-connecting-ip", "cf-ipcity", "cf-iplatitude",
|
||||
"cf-iplongitude", "cf-iptimezone", "cf-visitor", "cf-worker",
|
||||
"true-client-ip", "x-forwarded-for", "x-real-ip", "forwarded",
|
||||
};
|
||||
private static readonly HashSet<string> AllowList =
|
||||
new(CvdiagSchema.EdgeHeaderKeys, StringComparer.Ordinal);
|
||||
|
||||
// §6 tier matrix: per data-plane boundary, included-at-tier flags.
|
||||
private static readonly IReadOnlyDictionary<string, (bool Default, bool Verbose, bool Debug)> TierMatrix =
|
||||
new Dictionary<string, (bool, bool, bool)>
|
||||
{
|
||||
["probe.start"] = (false, true, true),
|
||||
["probe.navigate.complete"] = (false, true, true),
|
||||
["probe.message.send"] = (true, true, true),
|
||||
["probe.dom.container.mount"] = (true, true, true),
|
||||
["probe.dom.firsttoken"] = (true, true, true),
|
||||
["probe.dom.alternate_content"] = (true, true, true),
|
||||
["probe.sse.event"] = (false, true, true),
|
||||
["probe.sse.aborted"] = (true, true, true),
|
||||
["probe.network.error"] = (true, true, true),
|
||||
["probe.network.response"] = (true, true, true),
|
||||
["probe.console.error"] = (true, true, true),
|
||||
["probe.exit"] = (true, true, true),
|
||||
["backend.request.ingress"] = (false, true, true),
|
||||
["backend.agent.enter"] = (true, true, true),
|
||||
["backend.llm.call.start"] = (false, true, true),
|
||||
["backend.llm.call.heartbeat"] = (false, true, true),
|
||||
["backend.llm.call.response"] = (false, true, true),
|
||||
["backend.sse.first_byte"] = (false, true, true),
|
||||
["backend.sse.event"] = (false, false, true),
|
||||
["backend.sse.aborted"] = (true, true, true),
|
||||
["backend.agent.exit"] = (true, true, true),
|
||||
["backend.response.complete"] = (true, true, true),
|
||||
["backend.error.caught"] = (true, true, true),
|
||||
["aimock.request.ingress"] = (false, true, true),
|
||||
["aimock.match.decision"] = (false, true, true),
|
||||
["aimock.response.start"] = (false, true, true),
|
||||
["aimock.sse.chunk"] = (false, false, true),
|
||||
["aimock.response.aborted"] = (true, true, true),
|
||||
["aimock.response.complete"] = (true, true, true),
|
||||
};
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
new() { Timeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string?> _env;
|
||||
private readonly CvdiagLayer _defaultLayer;
|
||||
private readonly string? _pbWriteUrl;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _writeToStdout;
|
||||
private readonly long _debugDeadlineMs;
|
||||
private readonly Stopwatch _mono = Stopwatch.StartNew();
|
||||
|
||||
private long _debugEventCount;
|
||||
private bool _debugDisarmed;
|
||||
|
||||
public CvdiagTier Tier { get; }
|
||||
|
||||
public CvdiagEmitter(CvdiagEmitterOptions? options = null)
|
||||
{
|
||||
options ??= new CvdiagEmitterOptions();
|
||||
_env = options.Env ?? ReadProcessEnv();
|
||||
_defaultLayer = options.Layer;
|
||||
_pbWriteUrl = options.PbWriteUrl;
|
||||
_httpClient = options.HttpClient ?? SharedHttpClient;
|
||||
_writeToStdout = options.WriteToStdout;
|
||||
|
||||
var wantsDebug = options.Debug || _env.GetValueOrDefault("CVDIAG_DEBUG") == "1";
|
||||
var wantsVerbose = options.Verbose || _env.GetValueOrDefault("CVDIAG_VERBOSE") == "1";
|
||||
|
||||
if (wantsDebug)
|
||||
{
|
||||
AssertDebugAllowed();
|
||||
Tier = CvdiagTier.Debug;
|
||||
_debugDeadlineMs = NowMs() + DebugMaxWallclockMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
Tier = wantsVerbose ? CvdiagTier.Verbose : CvdiagTier.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the deployment-environment label (spec §6 production detection):
|
||||
/// SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT.
|
||||
/// Returns the lowercase label, or null if none resolves.
|
||||
/// </summary>
|
||||
public static string? ResolveEnvLabel(IReadOnlyDictionary<string, string?> env)
|
||||
{
|
||||
var raw = env.GetValueOrDefault("SHOWCASE_ENV")
|
||||
?? env.GetValueOrDefault("RAILWAY_ENVIRONMENT_NAME")
|
||||
?? env.GetValueOrDefault("ASPNETCORE_ENVIRONMENT");
|
||||
return string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBUG startup assertion (spec §6 hard bounds). Throws (fail-closed) when
|
||||
/// the env is production, unresolved (unknown == production), or no
|
||||
/// allow-list is provided. The ONE place the emitter may throw.
|
||||
/// </summary>
|
||||
private void AssertDebugAllowed()
|
||||
{
|
||||
var label = ResolveEnvLabel(_env);
|
||||
if (label is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is unresolved " +
|
||||
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT all unset); " +
|
||||
"fail-closed treats unknown env as production.");
|
||||
}
|
||||
if (label == "production")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is production.");
|
||||
}
|
||||
var allowList = _env.GetValueOrDefault("CVDIAG_DEBUG_ALLOW_LIST");
|
||||
if (string.IsNullOrWhiteSpace(allowList))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: CVDIAG_DEBUG_ALLOW_LIST is required " +
|
||||
"(comma-separated slug list) before DEBUG may start.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True iff the boundary is included at the current tier.</summary>
|
||||
public bool ShouldEmit(CvdiagBoundary boundary)
|
||||
{
|
||||
var wire = CvdiagSchema.WireValue(boundary);
|
||||
if (wire.StartsWith(AccountingPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true; // accounting events always emit
|
||||
}
|
||||
if (Tier == CvdiagTier.Debug && IsDebugExpired())
|
||||
{
|
||||
return TierMatrix.TryGetValue(wire, out var fb) && fb.Default;
|
||||
}
|
||||
if (!TierMatrix.TryGetValue(wire, out var row)) return false;
|
||||
return Tier switch
|
||||
{
|
||||
CvdiagTier.Default => row.Default,
|
||||
CvdiagTier.Verbose => row.Verbose,
|
||||
CvdiagTier.Debug => row.Debug,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsDebugExpired()
|
||||
{
|
||||
if (_debugDisarmed) return true;
|
||||
if (NowMs() >= _debugDeadlineMs || _debugEventCount >= DebugMaxEvents)
|
||||
{
|
||||
_debugDisarmed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit one event: tier-filter, mint ids, closed-world metadata filter,
|
||||
/// byte-cap, then EmitEvent (stdout + background PB write). Returns the
|
||||
/// built envelope, or null when filtered out / on failure. Never throws.
|
||||
/// </summary>
|
||||
public CvdiagEnvelope? Emit(CvdiagEmitArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldEmit(args.Boundary)) return null;
|
||||
if (Tier == CvdiagTier.Debug) _debugEventCount++;
|
||||
|
||||
var wire = CvdiagSchema.WireValue(args.Boundary);
|
||||
var isDataPlane = !wire.StartsWith(AccountingPrefix, StringComparison.Ordinal);
|
||||
|
||||
var metadata = new Dictionary<string, object?>();
|
||||
var metadataDropped = false;
|
||||
if (isDataPlane)
|
||||
{
|
||||
(metadata, metadataDropped) = FilterMetadata(wire, args.Metadata);
|
||||
}
|
||||
else if (args.Metadata is not null)
|
||||
{
|
||||
// Accounting events ride their payload verbatim (trusted internal).
|
||||
metadata = new Dictionary<string, object?>(args.Metadata);
|
||||
}
|
||||
|
||||
var testId = args.TestId ?? MintTestId();
|
||||
var envelope = new CvdiagEnvelope
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = testId,
|
||||
TraceId = testId,
|
||||
SpanId = MintSpanId(),
|
||||
ParentSpanId = args.ParentSpanId,
|
||||
Layer = args.Layer,
|
||||
Boundary = args.Boundary,
|
||||
Slug = args.Slug,
|
||||
Demo = args.Demo,
|
||||
Ts = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||
MonoNs = MonoNs(),
|
||||
DurationMs = args.DurationMs,
|
||||
Outcome = args.Outcome,
|
||||
EdgeHeaders = args.EdgeHeaders ?? new EdgeHeaders(),
|
||||
Metadata = metadata,
|
||||
MetadataDropped = metadataDropped,
|
||||
};
|
||||
|
||||
ApplyByteCap(envelope);
|
||||
EmitEvent(envelope);
|
||||
return envelope;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG emit failed boundary={args.Boundary} error={ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EmitEvent: write the single-line JSON to stdout AND kick off a
|
||||
/// fire-and-forget background PB write (≤1s). Never blocks the caller;
|
||||
/// background failures are swallowed to a stderr warning.
|
||||
/// </summary>
|
||||
public void EmitEvent(CvdiagEnvelope envelope)
|
||||
{
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG serialize failed error={ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writeToStdout) Console.Out.WriteLine(json);
|
||||
|
||||
if (string.IsNullOrEmpty(_pbWriteUrl)) return;
|
||||
// Fire-and-forget: do not await, do not throw into the boundary.
|
||||
_ = BackgroundWriteAsync(json);
|
||||
}
|
||||
|
||||
private async Task BackgroundWriteAsync(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
await _httpClient.PostAsync(_pbWriteUrl, content, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG pb-write failed error={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed-world metadata filter: keep only the boundary's schema keys; drop
|
||||
/// the rest and flag <c>_metadata_dropped</c> (spec §6). If the boundary has
|
||||
/// no key set (shouldn't happen for data-plane), keep nothing and flag drop.
|
||||
/// </summary>
|
||||
private static (Dictionary<string, object?> Metadata, bool Dropped) FilterMetadata(
|
||||
string wireBoundary, Dictionary<string, object?>? raw)
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
if (raw is null || raw.Count == 0) return (result, false);
|
||||
if (!CvdiagSchema.BoundaryMetadataKeys.TryGetValue(wireBoundary, out var allowed))
|
||||
{
|
||||
return (result, true);
|
||||
}
|
||||
var allowedSet = new HashSet<string>(allowed, StringComparer.Ordinal);
|
||||
var dropped = false;
|
||||
foreach (var (key, value) in raw)
|
||||
{
|
||||
if (allowedSet.Contains(key)) result[key] = value;
|
||||
else dropped = true;
|
||||
}
|
||||
return (result, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter a raw header bag to the closed 9-key <see cref="EdgeHeaders"/>:
|
||||
/// deny-list rejected first (deny wins, case-insensitive); non-allow keys
|
||||
/// dropped; every result carries all 9 keys (absent → null). No cf-ip*
|
||||
/// wildcard — only exact deny-list entries.
|
||||
/// </summary>
|
||||
public static EdgeHeaders FilterEdgeHeaders(IReadOnlyDictionary<string, string?> raw)
|
||||
{
|
||||
var kept = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var (rawKey, rawValue) in raw)
|
||||
{
|
||||
var key = rawKey.ToLowerInvariant();
|
||||
if (DenyList.Contains(key)) continue; // deny wins
|
||||
if (!AllowList.Contains(key)) continue; // closed-world
|
||||
kept[key] = rawValue;
|
||||
}
|
||||
string? Get(string k) => kept.TryGetValue(k, out var v) ? v : null;
|
||||
return new EdgeHeaders
|
||||
{
|
||||
CfRay = Get("cf-ray"),
|
||||
CfMitigated = Get("cf-mitigated"),
|
||||
CfCacheStatus = Get("cf-cache-status"),
|
||||
XRailwayEdge = Get("x-railway-edge"),
|
||||
XRailwayRequestId = Get("x-railway-request-id"),
|
||||
XHikariTrace = Get("x-hikari-trace"),
|
||||
RetryAfter = Get("retry-after"),
|
||||
Via = Get("via"),
|
||||
Server = Get("server"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim over-budget envelopes to the tier byte cap and stamp
|
||||
/// <c>_truncated</c> (spec §7). Metadata string values are trimmed first.
|
||||
/// </summary>
|
||||
private void ApplyByteCap(CvdiagEnvelope envelope)
|
||||
{
|
||||
var cap = ByteCapByTier[Tier];
|
||||
if (SerializedSize(envelope) <= cap) return;
|
||||
envelope.Truncated = true;
|
||||
foreach (var key in new List<string>(envelope.Metadata.Keys))
|
||||
{
|
||||
if (SerializedSize(envelope) <= cap) break;
|
||||
var value = envelope.Metadata[key];
|
||||
if (value is string s && s.Length > 64)
|
||||
{
|
||||
envelope.Metadata[key] = s[..61] + "...";
|
||||
}
|
||||
else if (value is not null && value is not (int or long or double or bool))
|
||||
{
|
||||
envelope.Metadata[key] = "[truncated]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int SerializedSize(CvdiagEnvelope envelope)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
return Encoding.UTF8.GetByteCount(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mint a UUIDv7 (RFC 9562): 48-bit Unix-ms timestamp, version nibble 7,
|
||||
/// variant bits 10. Lowercase hyphenated. Matches the TS mintTestId().
|
||||
/// </summary>
|
||||
public static string MintTestId(long? nowMs = null)
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var ts = (ulong)(nowMs ?? NowMs());
|
||||
bytes[0] = (byte)((ts >> 40) & 0xff);
|
||||
bytes[1] = (byte)((ts >> 32) & 0xff);
|
||||
bytes[2] = (byte)((ts >> 24) & 0xff);
|
||||
bytes[3] = (byte)((ts >> 16) & 0xff);
|
||||
bytes[4] = (byte)((ts >> 8) & 0xff);
|
||||
bytes[5] = (byte)(ts & 0xff);
|
||||
bytes[6] = (byte)((bytes[6] & 0x0f) | 0x70); // version 7
|
||||
bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80); // variant 10
|
||||
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}";
|
||||
}
|
||||
|
||||
/// <summary>Mint a 16-hex span id (8 random bytes).</summary>
|
||||
public static string MintSpanId()
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private long MonoNs() => (long)(_mono.Elapsed.TotalMilliseconds * 1e6);
|
||||
|
||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> ReadProcessEnv()
|
||||
{
|
||||
var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (e.Key is string k) dict[k] = e.Value as string;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// <auto-generated>
|
||||
// CvdiagSchema.cs — C# binding for the CVDIAG flap-observability envelope.
|
||||
// CODEGEN'd from the single IR `showcase/harness/src/cvdiag/schema.json`
|
||||
// ($id https://copilotkit.ai/schemas/cvdiag/v1.json, schema_version 1).
|
||||
// Plan unit L0-D; spec §5. Mirrors the canonical TS `schema.ts` / Pydantic /
|
||||
// Java bindings so all five languages share one wire contract.
|
||||
//
|
||||
// Source of truth: schema.json. To regenerate, re-run the .NET codegen step
|
||||
// (see L1-F build-context note in the slot report) — DO NOT hand-edit the
|
||||
// enum members / record fields below; edit schema.ts (→ schema.json) and
|
||||
// re-emit. The 3 enums, the EdgeHeaders record, the CvdiagEnvelope record,
|
||||
// the 29 per-boundary metadata records, and the closed key tables below are
|
||||
// derived 1:1 from schema.json `$defs`.
|
||||
//
|
||||
// System.Text.Json source-generation: `CvdiagJsonContext` carries the
|
||||
// [JsonSerializable] attributes so serialization is AOT/trim-safe and never
|
||||
// uses reflection-based serializers at runtime.
|
||||
// </auto-generated>
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Emitting layer (schema.json `$defs.layers`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagLayer>))]
|
||||
public enum CvdiagLayer
|
||||
{
|
||||
[JsonStringEnumMemberName("probe")] Probe,
|
||||
[JsonStringEnumMemberName("backend")] Backend,
|
||||
[JsonStringEnumMemberName("aimock")] Aimock,
|
||||
}
|
||||
|
||||
/// <summary>Event outcome (schema.json `$defs.outcomes`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagOutcome>))]
|
||||
public enum CvdiagOutcome
|
||||
{
|
||||
[JsonStringEnumMemberName("ok")] Ok,
|
||||
[JsonStringEnumMemberName("err")] Err,
|
||||
[JsonStringEnumMemberName("timeout")] Timeout,
|
||||
[JsonStringEnumMemberName("info")] Info,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary enum (schema.json `$defs.boundaries`, 33 members). The
|
||||
/// dotted wire values (e.g. "backend.response.complete") are carried by the
|
||||
/// [JsonStringEnumMemberName] attributes; the C# member names are the
|
||||
/// PascalCase'd, dot-stripped equivalents.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagBoundary>))]
|
||||
public enum CvdiagBoundary
|
||||
{
|
||||
[JsonStringEnumMemberName("probe.start")] ProbeStart,
|
||||
[JsonStringEnumMemberName("probe.navigate.complete")] ProbeNavigateComplete,
|
||||
[JsonStringEnumMemberName("probe.message.send")] ProbeMessageSend,
|
||||
[JsonStringEnumMemberName("probe.dom.container.mount")] ProbeDomContainerMount,
|
||||
[JsonStringEnumMemberName("probe.dom.firsttoken")] ProbeDomFirsttoken,
|
||||
[JsonStringEnumMemberName("probe.dom.alternate_content")] ProbeDomAlternateContent,
|
||||
[JsonStringEnumMemberName("probe.sse.event")] ProbeSseEvent,
|
||||
[JsonStringEnumMemberName("probe.sse.aborted")] ProbeSseAborted,
|
||||
[JsonStringEnumMemberName("probe.network.error")] ProbeNetworkError,
|
||||
[JsonStringEnumMemberName("probe.network.response")] ProbeNetworkResponse,
|
||||
[JsonStringEnumMemberName("probe.console.error")] ProbeConsoleError,
|
||||
[JsonStringEnumMemberName("probe.exit")] ProbeExit,
|
||||
[JsonStringEnumMemberName("backend.request.ingress")] BackendRequestIngress,
|
||||
[JsonStringEnumMemberName("backend.agent.enter")] BackendAgentEnter,
|
||||
[JsonStringEnumMemberName("backend.llm.call.start")] BackendLlmCallStart,
|
||||
[JsonStringEnumMemberName("backend.llm.call.heartbeat")] BackendLlmCallHeartbeat,
|
||||
[JsonStringEnumMemberName("backend.llm.call.response")] BackendLlmCallResponse,
|
||||
[JsonStringEnumMemberName("backend.sse.first_byte")] BackendSseFirstByte,
|
||||
[JsonStringEnumMemberName("backend.sse.event")] BackendSseEvent,
|
||||
[JsonStringEnumMemberName("backend.sse.aborted")] BackendSseAborted,
|
||||
[JsonStringEnumMemberName("backend.agent.exit")] BackendAgentExit,
|
||||
[JsonStringEnumMemberName("backend.response.complete")] BackendResponseComplete,
|
||||
[JsonStringEnumMemberName("backend.error.caught")] BackendErrorCaught,
|
||||
[JsonStringEnumMemberName("aimock.request.ingress")] AimockRequestIngress,
|
||||
[JsonStringEnumMemberName("aimock.match.decision")] AimockMatchDecision,
|
||||
[JsonStringEnumMemberName("aimock.response.start")] AimockResponseStart,
|
||||
[JsonStringEnumMemberName("aimock.sse.chunk")] AimockSseChunk,
|
||||
[JsonStringEnumMemberName("aimock.response.aborted")] AimockResponseAborted,
|
||||
[JsonStringEnumMemberName("aimock.response.complete")] AimockResponseComplete,
|
||||
[JsonStringEnumMemberName("cvdiag.purge_audit")] CvdiagPurgeAudit,
|
||||
[JsonStringEnumMemberName("cvdiag.collision_detected")] CvdiagCollisionDetected,
|
||||
[JsonStringEnumMemberName("cvdiag.queue_dropped")] CvdiagQueueDropped,
|
||||
[JsonStringEnumMemberName("cvdiag.metadata_dropped")] CvdiagMetadataDropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closed 9-key edge-header bag (schema.json `edge_headers`). Every field
|
||||
/// is always present on the wire; an absent header serializes as null. The
|
||||
/// hyphenated wire names are carried by [JsonPropertyName].
|
||||
/// </summary>
|
||||
public sealed record EdgeHeaders
|
||||
{
|
||||
[JsonPropertyName("cf-ray")] public string? CfRay { get; init; }
|
||||
[JsonPropertyName("cf-mitigated")] public string? CfMitigated { get; init; }
|
||||
[JsonPropertyName("cf-cache-status")] public string? CfCacheStatus { get; init; }
|
||||
[JsonPropertyName("x-railway-edge")] public string? XRailwayEdge { get; init; }
|
||||
[JsonPropertyName("x-railway-request-id")] public string? XRailwayRequestId { get; init; }
|
||||
[JsonPropertyName("x-hikari-trace")] public string? XHikariTrace { get; init; }
|
||||
[JsonPropertyName("retry-after")] public string? RetryAfter { get; init; }
|
||||
[JsonPropertyName("via")] public string? Via { get; init; }
|
||||
[JsonPropertyName("server")] public string? Server { get; init; }
|
||||
|
||||
/// <summary>Always 9 — the closed allow-list cardinality.</summary>
|
||||
public int KeyCount() => 9;
|
||||
|
||||
/// <summary>All 9 field values (for deny-leakage assertions/inspection).</summary>
|
||||
public IReadOnlyList<string?> AllValues() => new[]
|
||||
{
|
||||
CfRay, CfMitigated, CfCacheStatus, XRailwayEdge, XRailwayRequestId,
|
||||
XHikariTrace, RetryAfter, Via, Server,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CVDIAG envelope (schema.json root). Field/property names map to the
|
||||
/// snake_case wire keys via [JsonPropertyName]; the two optional `_`-prefixed
|
||||
/// stamp flags are written only when true (DefaultIgnoreCondition handles the
|
||||
/// false/null case via the source-gen context options).
|
||||
/// </summary>
|
||||
public sealed record CvdiagEnvelope
|
||||
{
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; init; } = CvdiagSchema.SchemaVersion;
|
||||
[JsonPropertyName("test_id")] public string TestId { get; init; } = "";
|
||||
[JsonPropertyName("trace_id")] public string TraceId { get; init; } = "";
|
||||
[JsonPropertyName("span_id")] public string SpanId { get; init; } = "";
|
||||
[JsonPropertyName("parent_span_id")] public string? ParentSpanId { get; init; }
|
||||
[JsonPropertyName("layer")] public CvdiagLayer Layer { get; init; }
|
||||
[JsonPropertyName("boundary")] public CvdiagBoundary Boundary { get; init; }
|
||||
[JsonPropertyName("slug")] public string Slug { get; init; } = "";
|
||||
[JsonPropertyName("demo")] public string Demo { get; init; } = "";
|
||||
[JsonPropertyName("ts")] public string Ts { get; init; } = "";
|
||||
[JsonPropertyName("mono_ns")] public long MonoNs { get; init; }
|
||||
[JsonPropertyName("duration_ms")] public long? DurationMs { get; init; }
|
||||
[JsonPropertyName("outcome")] public CvdiagOutcome Outcome { get; init; }
|
||||
[JsonPropertyName("edge_headers")] public EdgeHeaders EdgeHeaders { get; init; } = new();
|
||||
[JsonPropertyName("metadata")] public Dictionary<string, object?> Metadata { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("_metadata_dropped")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool MetadataDropped { get; set; }
|
||||
|
||||
[JsonPropertyName("_truncated")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool Truncated { get; set; }
|
||||
}
|
||||
|
||||
// ── Per-boundary metadata records (29; schema.json `$defs.boundary_metadata_keys`) ──
|
||||
//
|
||||
// One record per metadata-bearing boundary. These are convenience/typed views
|
||||
// over the boundary's closed key set; the on-wire `metadata` bag stays an open
|
||||
// JSON object (Dictionary<string, object?>) so a single envelope type carries
|
||||
// every boundary. Emitters MAY construct these and pass their property bag to
|
||||
// CvdiagEmitArgs.Metadata. Field names mirror the schema.json metadata keys.
|
||||
|
||||
public sealed record ProbeStartMeta { public string? Url { get; init; } public string? Viewport { get; init; } }
|
||||
public sealed record ProbeNavigateCompleteMeta { public string? Url { get; init; } public long? NavMs { get; init; } public int? HttpStatus { get; init; } }
|
||||
public sealed record ProbeMessageSendMeta { public int? MessageIndex { get; init; } public int? CharCount { get; init; } public string? Demo { get; init; } }
|
||||
public sealed record ProbeDomContainerMountMeta { public long? DeltaMsFromStart { get; init; } }
|
||||
public sealed record ProbeDomFirsttokenMeta { public long? DeltaMsFromStart { get; init; } public int? TextLength { get; init; } }
|
||||
public sealed record ProbeDomAlternateContentMeta { public Dictionary<string, int>? ChildTypeHistogram { get; init; } }
|
||||
public sealed record ProbeSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record ProbeSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record ProbeNetworkErrorMeta { public string? Url { get; init; } public string? ErrorClass { get; init; } public int? ResponseStatus { get; init; } }
|
||||
public sealed record ProbeNetworkResponseMeta { public string? Url { get; init; } public int? Status { get; init; } public int? ContentLength { get; init; } public long? DurationMs { get; init; } }
|
||||
public sealed record ProbeConsoleErrorMeta { public string? Level { get; init; } public string? MessageScrubbed { get; init; } public string? SourceFile { get; init; } public string? LineCol { get; init; } }
|
||||
public sealed record ProbeExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } public long? FirstTokenDeltaMs { get; init; } }
|
||||
public sealed record BackendRequestIngressMeta { public string? Method { get; init; } public string? Path { get; init; } public int? ContentLength { get; init; } }
|
||||
public sealed record BackendAgentEnterMeta { public string? AgentName { get; init; } public string? ModelId { get; init; } }
|
||||
public sealed record BackendLlmCallStartMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? PromptTokenCountEstimate { get; init; } }
|
||||
public sealed record BackendLlmCallHeartbeatMeta { public long? ElapsedMsSinceStart { get; init; } }
|
||||
public sealed record BackendLlmCallResponseMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? ResponseTokenCount { get; init; } public long? LatencyMs { get; init; } public string? ErrorClass { get; init; } }
|
||||
public sealed record BackendSseFirstByteMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record BackendSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record BackendSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record BackendAgentExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } }
|
||||
public sealed record BackendResponseCompleteMeta { public int? HttpStatus { get; init; } public int? ContentLength { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } }
|
||||
public sealed record BackendErrorCaughtMeta { public string? ExceptionType { get; init; } public string? MessageScrubbed { get; init; } public string? StackBrief { get; init; } public bool? Truncated { get; init; } }
|
||||
public sealed record AimockRequestIngressMeta { public string? Path { get; init; } public int? ContentLength { get; init; } public string? MatchKeys { get; init; } }
|
||||
public sealed record AimockMatchDecisionMeta { public string? FixtureId { get; init; } public double? MatchScore { get; init; } public IReadOnlyList<string>? RejectReasons { get; init; } }
|
||||
public sealed record AimockResponseStartMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record AimockSseChunkMeta { public int? ChunkSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record AimockResponseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record AimockResponseCompleteMeta { public int? HttpStatus { get; init; } public int? TotalBytes { get; init; } public long? TotalDurationMs { get; init; } public int? ChunkCount { get; init; } }
|
||||
|
||||
/// <summary>
|
||||
/// Static schema tables derived 1:1 from schema.json `$defs`. These back the
|
||||
/// closed-world metadata filter in <see cref="CvdiagEmitter"/> and the
|
||||
/// codegen-coverage assertions.
|
||||
/// </summary>
|
||||
public static class CvdiagSchema
|
||||
{
|
||||
/// <summary>schema.json `schema_version` (const 1).</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
/// <summary>schema.json `$defs.edge_header_keys` (9 keys, wire order).</summary>
|
||||
public static readonly IReadOnlyList<string> EdgeHeaderKeys = new[]
|
||||
{
|
||||
"cf-ray", "cf-mitigated", "cf-cache-status", "x-railway-edge",
|
||||
"x-railway-request-id", "x-hikari-trace", "retry-after", "via", "server",
|
||||
};
|
||||
|
||||
/// <summary>schema.json `$defs.boundaries` (all 33 wire values).</summary>
|
||||
public static readonly IReadOnlyList<string> AllBoundaries = new[]
|
||||
{
|
||||
"probe.start", "probe.navigate.complete", "probe.message.send",
|
||||
"probe.dom.container.mount", "probe.dom.firsttoken",
|
||||
"probe.dom.alternate_content", "probe.sse.event", "probe.sse.aborted",
|
||||
"probe.network.error", "probe.network.response", "probe.console.error",
|
||||
"probe.exit", "backend.request.ingress", "backend.agent.enter",
|
||||
"backend.llm.call.start", "backend.llm.call.heartbeat",
|
||||
"backend.llm.call.response", "backend.sse.first_byte", "backend.sse.event",
|
||||
"backend.sse.aborted", "backend.agent.exit", "backend.response.complete",
|
||||
"backend.error.caught", "aimock.request.ingress", "aimock.match.decision",
|
||||
"aimock.response.start", "aimock.sse.chunk", "aimock.response.aborted",
|
||||
"aimock.response.complete", "cvdiag.purge_audit",
|
||||
"cvdiag.collision_detected", "cvdiag.queue_dropped",
|
||||
"cvdiag.metadata_dropped",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// schema.json `$defs.boundary_metadata_keys` — the closed metadata key
|
||||
/// set per data-plane boundary (29 entries; the 4 `cvdiag.*` accounting
|
||||
/// boundaries carry no closed key set and ride their payload verbatim).
|
||||
/// Keyed by the dotted wire boundary value.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> BoundaryMetadataKeys =
|
||||
new Dictionary<string, IReadOnlyList<string>>
|
||||
{
|
||||
["probe.start"] = new[] { "url", "viewport" },
|
||||
["probe.navigate.complete"] = new[] { "url", "nav_ms", "http_status" },
|
||||
["probe.message.send"] = new[] { "message_index", "char_count", "demo" },
|
||||
["probe.dom.container.mount"] = new[] { "delta_ms_from_start" },
|
||||
["probe.dom.firsttoken"] = new[] { "delta_ms_from_start", "text_length" },
|
||||
["probe.dom.alternate_content"] = new[] { "child_type_histogram" },
|
||||
["probe.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["probe.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["probe.network.error"] = new[] { "url", "error_class", "response_status" },
|
||||
["probe.network.response"] = new[] { "url", "status", "content_length", "duration_ms" },
|
||||
["probe.console.error"] = new[] { "level", "message_scrubbed", "source_file", "line_col" },
|
||||
["probe.exit"] = new[] { "terminal_outcome", "total_duration_ms", "sse_event_count", "first_token_delta_ms" },
|
||||
["backend.request.ingress"] = new[] { "method", "path", "content_length" },
|
||||
["backend.agent.enter"] = new[] { "agent_name", "model_id" },
|
||||
["backend.llm.call.start"] = new[] { "provider", "model", "prompt_token_count_estimate" },
|
||||
["backend.llm.call.heartbeat"] = new[] { "elapsed_ms_since_start" },
|
||||
["backend.llm.call.response"] = new[] { "provider", "model", "response_token_count", "latency_ms", "error_class" },
|
||||
["backend.sse.first_byte"] = new[] { "delta_ms_from_ingress" },
|
||||
["backend.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["backend.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["backend.agent.exit"] = new[] { "terminal_outcome", "total_duration_ms" },
|
||||
["backend.response.complete"] = new[] { "http_status", "content_length", "total_duration_ms", "sse_event_count" },
|
||||
["backend.error.caught"] = new[] { "exception_type", "message_scrubbed", "stack_brief", "truncated" },
|
||||
["aimock.request.ingress"] = new[] { "path", "content_length", "match_keys" },
|
||||
["aimock.match.decision"] = new[] { "fixture_id", "match_score", "reject_reasons" },
|
||||
["aimock.response.start"] = new[] { "delta_ms_from_ingress" },
|
||||
["aimock.sse.chunk"] = new[] { "chunk_size_bytes", "sequence_num" },
|
||||
["aimock.response.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["aimock.response.complete"] = new[] { "http_status", "total_bytes", "total_duration_ms", "chunk_count" },
|
||||
};
|
||||
|
||||
/// <summary>The dotted wire value for a boundary enum member.</summary>
|
||||
public static string WireValue(CvdiagBoundary boundary) => boundary switch
|
||||
{
|
||||
CvdiagBoundary.ProbeStart => "probe.start",
|
||||
CvdiagBoundary.ProbeNavigateComplete => "probe.navigate.complete",
|
||||
CvdiagBoundary.ProbeMessageSend => "probe.message.send",
|
||||
CvdiagBoundary.ProbeDomContainerMount => "probe.dom.container.mount",
|
||||
CvdiagBoundary.ProbeDomFirsttoken => "probe.dom.firsttoken",
|
||||
CvdiagBoundary.ProbeDomAlternateContent => "probe.dom.alternate_content",
|
||||
CvdiagBoundary.ProbeSseEvent => "probe.sse.event",
|
||||
CvdiagBoundary.ProbeSseAborted => "probe.sse.aborted",
|
||||
CvdiagBoundary.ProbeNetworkError => "probe.network.error",
|
||||
CvdiagBoundary.ProbeNetworkResponse => "probe.network.response",
|
||||
CvdiagBoundary.ProbeConsoleError => "probe.console.error",
|
||||
CvdiagBoundary.ProbeExit => "probe.exit",
|
||||
CvdiagBoundary.BackendRequestIngress => "backend.request.ingress",
|
||||
CvdiagBoundary.BackendAgentEnter => "backend.agent.enter",
|
||||
CvdiagBoundary.BackendLlmCallStart => "backend.llm.call.start",
|
||||
CvdiagBoundary.BackendLlmCallHeartbeat => "backend.llm.call.heartbeat",
|
||||
CvdiagBoundary.BackendLlmCallResponse => "backend.llm.call.response",
|
||||
CvdiagBoundary.BackendSseFirstByte => "backend.sse.first_byte",
|
||||
CvdiagBoundary.BackendSseEvent => "backend.sse.event",
|
||||
CvdiagBoundary.BackendSseAborted => "backend.sse.aborted",
|
||||
CvdiagBoundary.BackendAgentExit => "backend.agent.exit",
|
||||
CvdiagBoundary.BackendResponseComplete => "backend.response.complete",
|
||||
CvdiagBoundary.BackendErrorCaught => "backend.error.caught",
|
||||
CvdiagBoundary.AimockRequestIngress => "aimock.request.ingress",
|
||||
CvdiagBoundary.AimockMatchDecision => "aimock.match.decision",
|
||||
CvdiagBoundary.AimockResponseStart => "aimock.response.start",
|
||||
CvdiagBoundary.AimockSseChunk => "aimock.sse.chunk",
|
||||
CvdiagBoundary.AimockResponseAborted => "aimock.response.aborted",
|
||||
CvdiagBoundary.AimockResponseComplete => "aimock.response.complete",
|
||||
CvdiagBoundary.CvdiagPurgeAudit => "cvdiag.purge_audit",
|
||||
CvdiagBoundary.CvdiagCollisionDetected => "cvdiag.collision_detected",
|
||||
CvdiagBoundary.CvdiagQueueDropped => "cvdiag.queue_dropped",
|
||||
CvdiagBoundary.CvdiagMetadataDropped => "cvdiag.metadata_dropped",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(boundary), boundary, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json source-generation context. Carries the [JsonSerializable]
|
||||
/// roots so serialization is reflection-free (AOT/trim-safe). The snake_case
|
||||
/// policy is NOT applied globally — every field already declares its exact
|
||||
/// wire name via [JsonPropertyName], so we keep the default naming and rely on
|
||||
/// the explicit attributes (avoids double-transforming hyphenated edge keys).
|
||||
///
|
||||
/// IMPORTANT: we do NOT set a context-wide DefaultIgnoreCondition. The schema
|
||||
/// REQUIRES the nullable fields (`parent_span_id`, `duration_ms`) to appear on
|
||||
/// the wire as `null`, so a global WhenWritingDefault/WhenWritingNull would
|
||||
/// drop them and break the closed-world `required` contract. The only fields
|
||||
/// that should be omitted when default are the two `_`-prefixed stamp flags,
|
||||
/// which carry their own per-property [JsonIgnore(WhenWritingDefault)].
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
[JsonSerializable(typeof(CvdiagEnvelope))]
|
||||
[JsonSerializable(typeof(EdgeHeaders))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
public partial class CvdiagJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone xUnit test project for the shared .NET CVDIAG binding (plan unit
|
||||
L0-D). The `_shared/dotnet/` directory is a SOURCE-INCLUDE shared library
|
||||
(no production .csproj of its own — see the L1-F reference note in the slot
|
||||
report), so this test project compiles the two binding sources directly via
|
||||
<Compile Include> rather than a <ProjectReference>. This keeps the binding
|
||||
buildable/testable in isolation without a host integration.
|
||||
|
||||
System.Text.Json source-generation requires C# 9+ partial-class contexts and
|
||||
the JsonStringEnumMemberName attribute (net8.0+); we target net9.0 to match
|
||||
the .NET integrations (ProverbsAgent.csproj is net9.0).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Copilotkit.Showcase.Cvdiag.Tests</RootNamespace>
|
||||
<AssemblyName>Cvdiag.Schema.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Compile the shared binding sources directly (source-include model). -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\CvdiagSchema.cs" />
|
||||
<Compile Include="..\CvdiagEmitter.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
// CvdiagSchema.test.cs — xUnit red-green proof for the .NET CVDIAG codegen
|
||||
// binding (plan unit L0-D, spec §5/§6). Mirrors the TS `schema.test.ts` /
|
||||
// `emit.ts` contract:
|
||||
//
|
||||
// 1. schema round-trip — a fully-populated CvdiagEnvelope serializes to JSON
|
||||
// and deserializes back identically (System.Text.Json source-gen).
|
||||
// 2. forbidden-header rejection — CvdiagEmitter.FilterEdgeHeaders() drops a
|
||||
// DENY-list header (cf-ipcountry) and the cf-ip* family even if it is
|
||||
// injected alongside an allow-list header; every result carries all 9
|
||||
// allow-list keys (absent → null).
|
||||
// 3. _metadata_dropped stamp — an envelope carrying an unknown metadata key
|
||||
// is closed-world filtered and the `_metadata_dropped` flag is set.
|
||||
// 4. production-env DEBUG refusal — CvdiagEmitter fail-closes (throws) when
|
||||
// DEBUG is requested in a production environment, when no env resolves,
|
||||
// and when the allow-list is absent.
|
||||
//
|
||||
// RED: with no CvdiagSchema.cs / CvdiagEmitter.cs these types do not exist and
|
||||
// the suite does not compile. GREEN once both are implemented.
|
||||
//
|
||||
// Run (from repo root, requires the .NET 9 SDK):
|
||||
// dotnet test showcase/integrations/_shared/dotnet/tests/CvdiagSchema.Tests.csproj
|
||||
|
||||
using System.Text.Json;
|
||||
using Copilotkit.Showcase.Cvdiag;
|
||||
using Xunit;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag.Tests;
|
||||
|
||||
public class CvdiagSchemaTests
|
||||
{
|
||||
private static EdgeHeaders EmptyEdgeHeaders() => new();
|
||||
|
||||
private static CvdiagEnvelope SampleEnvelope() => new()
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
TraceId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
SpanId = "00f067aa0ba902b7",
|
||||
ParentSpanId = null,
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Ts = "2026-06-18T12:00:00.000Z",
|
||||
MonoNs = 123456789,
|
||||
DurationMs = 42,
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
EdgeHeaders = EmptyEdgeHeaders(),
|
||||
Metadata = new Dictionary<string, object?> { ["http_status"] = 200 },
|
||||
};
|
||||
|
||||
// (1) Round-trip: serialize → deserialize → equal field-by-field.
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_ThroughSystemTextJson()
|
||||
{
|
||||
var original = SampleEnvelope();
|
||||
var json = JsonSerializer.Serialize(original, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
var back = JsonSerializer.Deserialize(json, CvdiagJsonContext.Default.CvdiagEnvelope)!;
|
||||
|
||||
Assert.Equal(original.SchemaVersion, back.SchemaVersion);
|
||||
Assert.Equal(original.TestId, back.TestId);
|
||||
Assert.Equal(original.SpanId, back.SpanId);
|
||||
Assert.Equal(original.Layer, back.Layer);
|
||||
Assert.Equal(original.Boundary, back.Boundary);
|
||||
Assert.Equal(original.Slug, back.Slug);
|
||||
Assert.Equal(original.Outcome, back.Outcome);
|
||||
Assert.Equal(original.MonoNs, back.MonoNs);
|
||||
Assert.Equal(original.DurationMs, back.DurationMs);
|
||||
}
|
||||
|
||||
// (1b) Wire format: enum + key names match the schema.json contract exactly
|
||||
// (snake_case keys, dotted/lowercase enum string values).
|
||||
[Fact]
|
||||
public void Envelope_SerializesWithSchemaWireNames()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(SampleEnvelope(), CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
Assert.Contains("\"schema_version\":1", json);
|
||||
Assert.Contains("\"test_id\":", json);
|
||||
Assert.Contains("\"parent_span_id\":null", json);
|
||||
Assert.Contains("\"edge_headers\":", json);
|
||||
Assert.Contains("\"layer\":\"backend\"", json);
|
||||
Assert.Contains("\"boundary\":\"backend.response.complete\"", json);
|
||||
Assert.Contains("\"outcome\":\"ok\"", json);
|
||||
// cf-ray etc. serialize with their hyphenated wire names.
|
||||
Assert.Contains("\"cf-ray\":null", json);
|
||||
Assert.Contains("\"x-railway-request-id\":null", json);
|
||||
}
|
||||
|
||||
// (2) Forbidden-header rejection: cf-ipcountry (DENY) is dropped even when
|
||||
// injected next to an allow-list header; the cf-ip* family is never
|
||||
// captured; all 9 allow-list keys are present (absent → null).
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_RejectsDenyListAndCfIpFamily()
|
||||
{
|
||||
var raw = new Dictionary<string, string?>
|
||||
{
|
||||
["cf-ray"] = "8abc-DFW", // allow → kept
|
||||
["cf-ipcountry"] = "US", // deny → dropped
|
||||
["cf-connecting-ip"] = "1.2.3.4", // deny → dropped
|
||||
["true-client-ip"] = "1.2.3.4", // deny → dropped
|
||||
["x-forwarded-for"] = "1.2.3.4", // deny → dropped
|
||||
["CF-IPCity"] = "Dallas", // deny (case-insensitive)
|
||||
["server"] = "railway-edge", // allow → kept
|
||||
};
|
||||
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
|
||||
Assert.Equal("8abc-DFW", filtered.CfRay);
|
||||
Assert.Equal("railway-edge", filtered.Server);
|
||||
// DENY-list values never surface on any of the 9 fields.
|
||||
Assert.DoesNotContain("US", filtered.AllValues());
|
||||
Assert.DoesNotContain("1.2.3.4", filtered.AllValues());
|
||||
Assert.DoesNotContain("Dallas", filtered.AllValues());
|
||||
// All 9 allow-list keys present; unset → null.
|
||||
Assert.Null(filtered.CfMitigated);
|
||||
Assert.Null(filtered.XRailwayRequestId);
|
||||
Assert.Null(filtered.RetryAfter);
|
||||
Assert.Equal(9, filtered.KeyCount());
|
||||
}
|
||||
|
||||
// (2b) Deny wins even if a deny key somehow appears in the allow path.
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_DenyWinsOverAllow()
|
||||
{
|
||||
var raw = new Dictionary<string, string?> { ["forwarded"] = "for=1.2.3.4" };
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
Assert.DoesNotContain("for=1.2.3.4", filtered.AllValues());
|
||||
}
|
||||
|
||||
// (3) _metadata_dropped stamp: an unknown metadata key for the boundary is
|
||||
// dropped (closed-world) and the flag is stamped.
|
||||
[Fact]
|
||||
public void Emit_StampsMetadataDropped_OnUnknownKey()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["http_status"] = 200, // allowed for this boundary
|
||||
["totally_unknown_key"] = 1, // not in the closed key set → dropped
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.True(env!.MetadataDropped);
|
||||
Assert.True(env.Metadata.ContainsKey("http_status"));
|
||||
Assert.False(env.Metadata.ContainsKey("totally_unknown_key"));
|
||||
}
|
||||
|
||||
// (3b) Clean metadata leaves the flag unset.
|
||||
[Fact]
|
||||
public void Emit_DoesNotStampMetadataDropped_OnCleanMetadata()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendAgentEnter,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["agent_name"] = "proverbs",
|
||||
["model_id"] = "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.False(env!.MetadataDropped);
|
||||
}
|
||||
|
||||
// (4) Production-env DEBUG refusal: SHOWCASE_ENV=production + DEBUG throws.
|
||||
[Fact]
|
||||
public void DebugInProduction_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "production",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4b) Unresolved env (no SHOWCASE_ENV/RAILWAY/ASPNETCORE) treated as prod.
|
||||
[Fact]
|
||||
public void DebugWithUnresolvedEnv_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4c) DEBUG in a non-prod env without an allow-list still refuses.
|
||||
[Fact]
|
||||
public void DebugWithoutAllowList_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4d) DEBUG in a non-prod env WITH an allow-list is permitted (no throw).
|
||||
[Fact]
|
||||
public void DebugInStagingWithAllowList_IsAllowed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
var emitter = new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env });
|
||||
Assert.Equal(CvdiagTier.Debug, emitter.Tier);
|
||||
}
|
||||
|
||||
// (4e) Tier env precedence: ASPNETCORE_ENVIRONMENT is the last fallback
|
||||
// (the .NET analogue of NODE_ENV).
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_FallsBackToAspNetCoreEnvironment()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["ASPNETCORE_ENVIRONMENT"] = "Production" };
|
||||
Assert.Equal("production", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_ShowcaseEnvWins()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["RAILWAY_ENVIRONMENT_NAME"] = "production",
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
};
|
||||
Assert.Equal("staging", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
// Codegen coverage: all 29 metadata-bearing boundaries have a closed key set.
|
||||
[Fact]
|
||||
public void AllBoundaries_HaveClosedMetadataKeySets()
|
||||
{
|
||||
Assert.Equal(33, CvdiagSchema.AllBoundaries.Count);
|
||||
Assert.Equal(29, CvdiagSchema.BoundaryMetadataKeys.Count);
|
||||
Assert.Equal(9, CvdiagSchema.EdgeHeaderKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Small factory: a VERBOSE-tier emitter with no PB writer for metadata tests.
|
||||
internal static class CvdiagEmitterTestFactory
|
||||
{
|
||||
public static CvdiagEmitter Verbose() => new(new CvdiagEmitterOptions
|
||||
{
|
||||
Verbose = true,
|
||||
Env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""conftest.py — put ``showcase/integrations`` on ``sys.path`` so the test suite
|
||||
can ``import _shared.*`` exactly as the runtime does (``/app`` on PYTHONPATH,
|
||||
``/app/_shared`` the package).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# showcase/integrations/_shared/tests/conftest.py → showcase/integrations
|
||||
_INTEGRATIONS_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(_INTEGRATIONS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_INTEGRATIONS_DIR))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""test_cvdiag_emit_gate_degrade.py — M5 CR R3 functional regression for C1: the
|
||||
fail-closed DEBUG degrade must actually SUPPRESS emission.
|
||||
|
||||
Background: a prior fix made ``cvdiag_bootstrap.setup()`` DEGRADE
|
||||
(``_ENABLED=False``, instrumentation disabled) instead of crashing the backend on
|
||||
a fail-closed DEBUG misconfig. But the emit path never consulted that flag —
|
||||
``emit_cvdiag`` wrote the ``CVDIAG`` envelope to stdout regardless of
|
||||
``is_enabled()``. A degraded backend therefore STILL emitted default-tier
|
||||
boundaries whenever the per-integration gate (``CVDIAG_BACKEND_EMITTER=1``) was
|
||||
on. ``is_enabled()`` was a dead flag.
|
||||
|
||||
RED (pre-fix): a degraded ``setup()`` (``_ENABLED=False``) followed by
|
||||
``emit_cvdiag(...)`` still writes a ``CVDIAG `` line to stdout.
|
||||
GREEN (post-fix): the degraded emit writes NOTHING; the normal (enabled) path
|
||||
still emits, so the gate isn't over-tightened.
|
||||
|
||||
Imports the package as ``_shared.*`` to mirror the runtime layout; conftest.py
|
||||
puts ``showcase/integrations`` on sys.path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
# A valid UUIDv7 test_id (version nibble 7, variant 8/9/a/b).
|
||||
_VALID_TEST_ID = "018f8b2a-7c3e-7a1b-9f4d-0123456789ab"
|
||||
_VALID_SPAN_ID = "0123456789abcdef"
|
||||
|
||||
|
||||
def _base_envelope(**overrides):
|
||||
"""A schema-valid default-tier boundary envelope (backend.agent.enter)."""
|
||||
env = {
|
||||
"schema_version": 1,
|
||||
"test_id": _VALID_TEST_ID,
|
||||
"trace_id": _VALID_TEST_ID,
|
||||
"span_id": _VALID_SPAN_ID,
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.agent.enter",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T12:00:00Z",
|
||||
"mono_ns": 123456789,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {
|
||||
"cf-ray": None,
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
},
|
||||
"metadata": {"agent_name": "weather", "model_id": "claude"},
|
||||
}
|
||||
env.update(overrides)
|
||||
return env
|
||||
|
||||
|
||||
def test_degraded_setup_suppresses_emission(capsys):
|
||||
"""A degraded backend (``_ENABLED=False``) must emit NOTHING.
|
||||
|
||||
Reproduces the prod-reachable path ``CVDIAG_BACKEND_EMITTER=1`` +
|
||||
``CVDIAG_DEBUG=1`` (unresolved env → fail-closed degrade): the per-integration
|
||||
gate is armed, but the shared bootstrap degraded instrumentation OFF. The
|
||||
shared ``emit_cvdiag`` is the single chokepoint every integration routes
|
||||
through, so it must honor ``is_enabled()``.
|
||||
|
||||
RED (pre-fix): ``emit_cvdiag`` ignores ``_ENABLED`` and still writes the
|
||||
``CVDIAG `` line.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
# Degrade: fail-closed DEBUG with an unresolved env disables instrumentation.
|
||||
cvdiag_bootstrap.setup({"CVDIAG_BACKEND_EMITTER": "1", "CVDIAG_DEBUG": "1"})
|
||||
assert cvdiag_bootstrap.is_enabled() is False, "precondition: setup degraded"
|
||||
|
||||
cvdiag_bootstrap.emit_cvdiag(_base_envelope())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "CVDIAG " not in out, (
|
||||
"degraded backend must suppress emission; got stdout:\n" + out
|
||||
)
|
||||
|
||||
|
||||
def test_enabled_setup_still_emits(capsys):
|
||||
"""Sanity / over-tightening guard: the normal enabled path still emits.
|
||||
|
||||
GREEN must not silence a healthy backend — a non-degraded ``setup()`` keeps
|
||||
``emit_cvdiag`` writing the ``CVDIAG `` envelope line.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_BACKEND_EMITTER": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.is_enabled() is True, "precondition: setup enabled"
|
||||
|
||||
cvdiag_bootstrap.emit_cvdiag(_base_envelope())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "CVDIAG " in out, "enabled backend must still emit; got stdout:\n" + out
|
||||
@@ -0,0 +1,133 @@
|
||||
"""test_cvdiag_inert_when_disabled.py — M5 CR R4 functional regression for N3:
|
||||
importing the cvdiag bootstrap must be FULLY INERT when cvdiag is disabled.
|
||||
|
||||
Background (N3): ``cvdiag_bootstrap.setup()`` ran
|
||||
``logging.basicConfig(level=…, format=…, force=True)`` unconditionally at import
|
||||
time (the module calls ``setup()`` at the bottom). ``force=True`` REMOVES and
|
||||
CLOSES every pre-existing root-logger handler, so merely importing the module —
|
||||
which every Python integration backend does whenever it imports
|
||||
``_cvdiag_backend`` — ripped out the HOST application's logging configuration,
|
||||
even with the backend emitter OFF (``CVDIAG_BACKEND_EMITTER`` unset, the
|
||||
canary-safe default). That violates the "byte-for-byte inert when disabled /
|
||||
canary-safe" contract the backend emitters advertise; the canonical TypeScript
|
||||
emitter performs NO equivalent global mutation.
|
||||
|
||||
RED (pre-fix): a pre-configured host root-logger handler is TORN DOWN by the
|
||||
import-time ``basicConfig(force=True)``.
|
||||
GREEN (post-fix): importing the bootstrap with cvdiag disabled is fully inert —
|
||||
the host root handler survives, nothing is written to stdout, and no rogue
|
||||
non-daemon threads are spawned.
|
||||
|
||||
This locks the CLASS of "fully inert when disabled" so future CR rounds stop
|
||||
re-discovering individual instances of host-state mutation at import.
|
||||
|
||||
The assertions run in a FRESH SUBPROCESS so the import-time side effects of
|
||||
``cvdiag_bootstrap`` are actually observable (a same-process import is cached and
|
||||
``basicConfig`` is a no-op the second time).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# showcase/integrations/_shared/tests/ → showcase/integrations
|
||||
_INTEGRATIONS_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Child program: pre-configure a host root handler, import the bootstrap with
|
||||
# cvdiag DISABLED, then assert the import was fully inert. Printed sentinels are
|
||||
# parsed by the parent so a failure produces a readable diff.
|
||||
_CHILD = r"""
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
# (host) Pre-configure the application's own root-logger handler BEFORE importing
|
||||
# cvdiag — this is the state a real host app has when the backend boots.
|
||||
_host_handler = logging.StreamHandler(sys.stderr)
|
||||
_host_handler.set_name("HOST_ROOT_HANDLER")
|
||||
_root = logging.getLogger()
|
||||
_root.addHandler(_host_handler)
|
||||
_root.setLevel(logging.INFO)
|
||||
|
||||
_threads_before = {t.ident for t in threading.enumerate()}
|
||||
|
||||
import io
|
||||
_cap = io.StringIO()
|
||||
_real_stdout = sys.stdout
|
||||
sys.stdout = _cap
|
||||
try:
|
||||
# cvdiag DISABLED: backend emitter OFF (canary-safe default), no debug.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401 (import-time setup() runs here)
|
||||
finally:
|
||||
sys.stdout = _real_stdout
|
||||
|
||||
_stdout_text = _cap.getvalue()
|
||||
|
||||
# (1) The host's pre-existing root handler MUST survive the import. This is the
|
||||
# RED trigger on the force=True code (basicConfig(force=True) removed it).
|
||||
_host_present = any(
|
||||
getattr(h, "get_name", lambda: None)() == "HOST_ROOT_HANDLER"
|
||||
for h in logging.getLogger().handlers
|
||||
)
|
||||
|
||||
# (2) Zero CVDIAG lines written to stdout at import (disabled → nothing emitted).
|
||||
_no_cvdiag = "CVDIAG " not in _stdout_text
|
||||
|
||||
# (3) No rogue non-daemon threads spawned by the import.
|
||||
_new_threads = [
|
||||
t for t in threading.enumerate()
|
||||
if t.ident not in _threads_before and not t.daemon
|
||||
]
|
||||
_no_nondaemon_threads = len(_new_threads) == 0
|
||||
|
||||
print("HOST_PRESENT=" + ("1" if _host_present else "0"))
|
||||
print("NO_CVDIAG=" + ("1" if _no_cvdiag else "0"))
|
||||
print("NO_NONDAEMON_THREADS=" + ("1" if _no_nondaemon_threads else "0"))
|
||||
print("NEW_NONDAEMON_THREADS=" + ",".join(t.name for t in _new_threads))
|
||||
"""
|
||||
|
||||
|
||||
def _run_child() -> dict[str, str]:
|
||||
env_clean = {
|
||||
# Inherit a minimal env but ensure cvdiag is fully disabled.
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PYTHONPATH": str(_INTEGRATIONS_DIR),
|
||||
# Explicitly NOT setting CVDIAG_BACKEND_EMITTER / CVDIAG_DEBUG /
|
||||
# CVDIAG_VERBOSE — disabled, canary-safe default.
|
||||
}
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _CHILD],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env_clean,
|
||||
cwd=str(_INTEGRATIONS_DIR),
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
"child process failed:\nSTDOUT:\n" + proc.stdout + "\nSTDERR:\n" + proc.stderr
|
||||
)
|
||||
result: dict[str, str] = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def test_import_is_fully_inert():
|
||||
"""Importing cvdiag_bootstrap (disabled) must not mutate host logging state."""
|
||||
r = _run_child()
|
||||
|
||||
assert r.get("HOST_PRESENT") == "1", (
|
||||
"host root-logger handler was torn down by the import-time "
|
||||
"basicConfig(force=True); the disabled cvdiag bootstrap must be inert"
|
||||
)
|
||||
assert r.get("NO_CVDIAG") == "1", (
|
||||
"a disabled cvdiag import wrote a CVDIAG line to stdout"
|
||||
)
|
||||
assert r.get("NO_NONDAEMON_THREADS") == "1", (
|
||||
"a disabled cvdiag import spawned non-daemon thread(s): "
|
||||
+ r.get("NEW_NONDAEMON_THREADS", "")
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""test_cvdiag_schema.py — L0-C unit suite for the Python ``_shared`` CVDIAG
|
||||
bootstrap module (6 tests, spec §5/§6).
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python3 -m pytest showcase/integrations/_shared/tests/
|
||||
|
||||
These tests import the package as ``_shared.*`` to mirror the runtime layout
|
||||
(``/app`` on PYTHONPATH, ``/app/_shared`` the package). ``conftest.py`` puts
|
||||
``showcase/integrations`` on ``sys.path`` so ``import _shared`` resolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from _shared import cvdiag_schema as schema
|
||||
|
||||
# A valid UUIDv7 test_id (version nibble 7, variant 8/9/a/b).
|
||||
_VALID_TEST_ID = "018f8b2a-7c3e-7a1b-9f4d-0123456789ab"
|
||||
_VALID_SPAN_ID = "0123456789abcdef"
|
||||
|
||||
|
||||
def _base_envelope(**overrides):
|
||||
env = {
|
||||
"schema_version": 1,
|
||||
"test_id": _VALID_TEST_ID,
|
||||
"trace_id": _VALID_TEST_ID,
|
||||
"span_id": _VALID_SPAN_ID,
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.agent.enter",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T12:00:00Z",
|
||||
"mono_ns": 123456789,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {
|
||||
"cf-ray": None,
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
},
|
||||
"metadata": {"agent_name": "weather", "model_id": "claude"},
|
||||
}
|
||||
env.update(overrides)
|
||||
return env
|
||||
|
||||
|
||||
def test_valid_envelope_round_trips():
|
||||
"""A well-formed envelope validates and round-trips by alias."""
|
||||
model = schema.CvdiagEnvelope.model_validate(_base_envelope())
|
||||
assert model.boundary is schema.CvdiagBoundary.BACKEND_AGENT_ENTER
|
||||
assert model.layer is schema.CvdiagLayer.BACKEND
|
||||
assert model.outcome is schema.CvdiagOutcome.OK
|
||||
assert model.metadata_dropped is False
|
||||
dumped = model.model_dump(by_alias=True)
|
||||
# The edge-header alias keys survive the round-trip.
|
||||
assert dumped["edge_headers"]["cf-ray"] is None
|
||||
assert dumped["_metadata_dropped"] is False
|
||||
|
||||
|
||||
def test_unknown_metadata_key_stamps_metadata_dropped():
|
||||
"""An unknown metadata key sets ``_metadata_dropped`` on the envelope."""
|
||||
env = _base_envelope(
|
||||
metadata={"agent_name": "weather", "bogus_key": "x"},
|
||||
)
|
||||
model = schema.CvdiagEnvelope.model_validate(env)
|
||||
assert model.metadata_dropped is True
|
||||
# Also true for an unknown TOP-LEVEL key.
|
||||
env2 = _base_envelope()
|
||||
env2["totally_unknown"] = "y"
|
||||
model2 = schema.CvdiagEnvelope.model_validate(env2)
|
||||
assert model2.metadata_dropped is True
|
||||
|
||||
|
||||
def test_forbidden_edge_header_dropped():
|
||||
"""A deny-list edge header (cf-connecting-ip) is rejected by EdgeHeaders.
|
||||
|
||||
``EdgeHeaders`` forbids extra keys, so a forbidden header can never round
|
||||
-trip through the closed model.
|
||||
"""
|
||||
bad = {
|
||||
"cf-ray": "abc",
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
"cf-connecting-ip": "1.2.3.4", # forbidden PII header
|
||||
}
|
||||
with pytest.raises(Exception):
|
||||
schema.EdgeHeaders.model_validate(bad)
|
||||
|
||||
|
||||
def test_uuidv7_test_id_validation():
|
||||
"""UUIDv7 test_id passes; a UUIDv4 / malformed test_id is rejected."""
|
||||
# Pass.
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id=_VALID_TEST_ID))
|
||||
# UUIDv4 (version nibble 4) → reject.
|
||||
uuid_v4 = "018f8b2a-7c3e-4a1b-9f4d-0123456789ab"
|
||||
with pytest.raises(Exception):
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id=uuid_v4))
|
||||
# Malformed → reject.
|
||||
with pytest.raises(Exception):
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id="not-a-uuid"))
|
||||
|
||||
|
||||
def test_debug_in_production_degrades_at_setup():
|
||||
"""``CVDIAG_DEBUG=1`` + ``SHOWCASE_ENV=production`` fails closed at setup.
|
||||
|
||||
Fail-closed intent: instrumentation is DISABLED (tier stays ``default``,
|
||||
``is_enabled()`` False). Degrade-not-crash: ``setup()`` must NOT raise —
|
||||
a misconfig may not abort the backend's module import.
|
||||
"""
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "production"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# Unresolved env is ALSO treated as production (fail-closed → degraded).
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# A non-production env with DEBUG is allowed (instrumentation enabled).
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.current_tier() == "debug"
|
||||
assert cvdiag_bootstrap.is_enabled() is True
|
||||
|
||||
|
||||
def test_basicconfig_captures_agents_logger_output(capsys):
|
||||
"""``setup()`` installs a handler so ``agents.*`` loggers actually emit.
|
||||
|
||||
This is the silent-drop regression guard: before bootstrap configures the
|
||||
root logger, an ``agents._header_forwarding`` ``logger.info`` is dropped;
|
||||
after ``setup()`` it reaches the stream.
|
||||
"""
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"SHOWCASE_ENV": "staging"})
|
||||
fwd_logger = logging.getLogger("agents._header_forwarding")
|
||||
fwd_logger.info("CVDIAG component=test boundary=probe.start status=ok")
|
||||
captured = capsys.readouterr()
|
||||
combined = captured.out + captured.err
|
||||
assert "CVDIAG component=test boundary=probe.start" in combined
|
||||
@@ -0,0 +1,164 @@
|
||||
"""test_cvdiag_writer_bootstrap.py — functional regression suite for the three
|
||||
M5 CR R1 fixes in the Python ``_shared`` CVDIAG core:
|
||||
|
||||
FIX-1 cvdiag_pb_writer drain loop never-propagate: a non-JSON-serializable
|
||||
envelope must NOT kill the flush daemon (mirrors TS pb-writer
|
||||
writeBatch — one bad row degrades, the batch/daemon survives).
|
||||
FIX-2 cvdiag_bootstrap.setup() degrade-not-crash: a fail-closed DEBUG
|
||||
misconfig must DISABLE instrumentation, not raise out of import.
|
||||
FIX-3 cvdiag_bootstrap.setup() idempotency: a repeated call is a no-op and
|
||||
never orphans a second daemon thread / PB writer.
|
||||
|
||||
Imports the package as ``_shared.*`` to mirror the runtime layout; conftest.py
|
||||
puts ``showcase/integrations`` on sys.path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from _shared import cvdiag_bootstrap
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
|
||||
|
||||
class _Unserializable:
|
||||
"""An object json.dumps cannot encode (raises TypeError in _post)."""
|
||||
|
||||
|
||||
def _drain_thread_alive(writer: CvdiagPbWriter) -> bool:
|
||||
worker = writer._worker
|
||||
return worker is not None and worker.is_alive()
|
||||
|
||||
|
||||
# ── FIX-1: drain loop never-propagate ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_drain_survives_non_json_serializable_envelope(monkeypatch):
|
||||
"""A non-JSON-serializable record must not kill the daemon; a later valid
|
||||
record still flushes.
|
||||
|
||||
RED (pre-fix): ``_post`` lets ``TypeError`` from ``json.dumps`` escape the
|
||||
``except (URLError, OSError, ValueError)`` clause; the exception unwinds
|
||||
``_run`` and the daemon thread dies — the later valid envelope is never
|
||||
POSTed.
|
||||
"""
|
||||
writer = CvdiagPbWriter(pb_url="http://pb.invalid", flush_window_s=0.05)
|
||||
|
||||
posted: list[dict] = []
|
||||
|
||||
# Stub the HTTP layer: record what reaches the wire after json.dumps.
|
||||
def _fake_post(envelope):
|
||||
# Re-run the real serialization seam so a bad envelope still throws
|
||||
# inside the drain loop, but a good one is "delivered" without network.
|
||||
import json as _json
|
||||
|
||||
_json.dumps(envelope) # raises TypeError on the bad envelope
|
||||
posted.append(envelope)
|
||||
|
||||
monkeypatch.setattr(writer, "_post", _fake_post)
|
||||
|
||||
# Bad record first — pre-fix this kills the drain thread.
|
||||
writer.enqueue({"bad": _Unserializable()})
|
||||
# Give the worker a couple of flush windows to process the bad record.
|
||||
time.sleep(0.2)
|
||||
|
||||
# Then a perfectly valid record.
|
||||
writer.enqueue({"ok": True, "n": 1})
|
||||
time.sleep(0.2)
|
||||
|
||||
assert _drain_thread_alive(writer), "drain daemon must survive a bad record"
|
||||
assert {"ok": True, "n": 1} in posted, "valid record must still flush"
|
||||
|
||||
|
||||
def test_post_swallows_typeerror_on_unserializable(monkeypatch):
|
||||
"""``_post`` itself must not raise on a non-serializable envelope.
|
||||
|
||||
RED (pre-fix): the ``TypeError`` from ``json.dumps`` is uncaught and
|
||||
propagates out of ``_post`` (only URLError/OSError/ValueError are caught).
|
||||
"""
|
||||
writer = CvdiagPbWriter(pb_url="http://pb.invalid")
|
||||
# Should NOT raise — _post is the never-throw persistence seam.
|
||||
writer._post({"bad": _Unserializable()})
|
||||
|
||||
|
||||
# ── FIX-2: bootstrap degrade-not-crash ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_setup_degrades_on_failclosed_debug_misconfig():
|
||||
"""A fail-closed DEBUG misconfig DISABLES instrumentation instead of raising.
|
||||
|
||||
RED (pre-fix): ``setup({"CVDIAG_DEBUG": "1"})`` (unresolved env → treated as
|
||||
production) raises ``RuntimeError`` out of setup(), which at import time
|
||||
would abort the whole backend module import.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
# Must NOT raise.
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1"})
|
||||
# Fail-closed intent preserved: instrumentation is OFF (tier not debug).
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# Explicit production env with DEBUG also degrades, never raises.
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "production"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
|
||||
def test_setup_allows_debug_in_nonproduction():
|
||||
"""A non-production DEBUG request still enables debug tier (intent intact)."""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.current_tier() == "debug"
|
||||
assert cvdiag_bootstrap.is_enabled() is True
|
||||
|
||||
|
||||
# ── FIX-3: bootstrap idempotency ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_setup_is_idempotent_no_orphan_daemon():
|
||||
"""A second setup() is a no-op: no second PB writer / daemon thread.
|
||||
|
||||
RED (pre-fix): no ``_SETUP_DONE`` guard in setup(), so a second call rebuilds
|
||||
``_PB_WRITER`` (orphaning the first writer's queue) and a subsequent enqueue
|
||||
spins a second ``cvdiag-pb-writer`` daemon thread.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
|
||||
# Count daemon threads BEFORE this test so the assertion is robust to
|
||||
# daemons left alive by earlier tests in the suite (short-lived best-effort
|
||||
# daemons are not joined on reset).
|
||||
def _pb_daemon_count() -> int:
|
||||
return sum(1 for t in threading.enumerate() if t.name == "cvdiag-pb-writer")
|
||||
|
||||
before = _pb_daemon_count()
|
||||
|
||||
cvdiag_bootstrap.setup(
|
||||
{"SHOWCASE_ENV": "staging", "CVDIAG_PB_URL": "http://pb.invalid"}
|
||||
)
|
||||
first_writer = cvdiag_bootstrap._PB_WRITER
|
||||
assert first_writer is not None
|
||||
first_writer.enqueue({"n": 1})
|
||||
time.sleep(0.05)
|
||||
after_first = _pb_daemon_count()
|
||||
assert after_first == before + 1, (
|
||||
"first setup()+enqueue must spin exactly one daemon"
|
||||
)
|
||||
|
||||
cvdiag_bootstrap.setup(
|
||||
{"SHOWCASE_ENV": "staging", "CVDIAG_PB_URL": "http://pb.invalid"}
|
||||
)
|
||||
second_writer = cvdiag_bootstrap._PB_WRITER
|
||||
|
||||
assert second_writer is first_writer, (
|
||||
"second setup() must not rebuild the PB writer"
|
||||
)
|
||||
|
||||
second_writer.enqueue({"n": 2})
|
||||
time.sleep(0.05)
|
||||
after_second = _pb_daemon_count()
|
||||
# The second setup()+enqueue must NOT spin an additional daemon.
|
||||
assert after_second == after_first, (
|
||||
f"second setup() orphaned a daemon: {before}->{after_first}->{after_second}"
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""test_cvdiag_writer_live_pb.py — live-PocketBase auth proof for the Python
|
||||
CVDIAG PB writer.
|
||||
|
||||
Boots an ACTUAL PocketBase 0.22 server using the EXACT production migrations
|
||||
(showcase/pocketbase/pb_migrations), which create ``cvdiag_events`` + the
|
||||
``cvdiag_api_keys`` auth collection + the seeded role-keyed records (writer /
|
||||
purge / migration). It then drives the real ``CvdiagPbWriter`` against that
|
||||
server and asserts the CREATE-only ACL is satisfied **as the writer role**.
|
||||
|
||||
WHY WRITER-ROLE (not superuser): the PB superuser bypasses ALL collection
|
||||
rules, so a writer that never authenticates would still "succeed" if probed as
|
||||
a superuser — that is exactly how the original defect (the inert
|
||||
``X-Cvdiag-Writer-Key`` header) escaped review. The createRule
|
||||
|
||||
@request.auth.collectionName = "cvdiag_api_keys" && @request.auth.role = "writer"
|
||||
|
||||
is only satisfiable by authenticating as the seeded writer record and sending
|
||||
``Authorization: Bearer <token>``. We therefore drive the writer with the
|
||||
writer record's PASSWORD as ``CVDIAG_WRITER_KEY`` and verify the row lands by
|
||||
querying ``cvdiag_events`` AS THE SUPERUSER (read-only, rule-bypassing) — the
|
||||
write path under test is never superuser.
|
||||
|
||||
Requires a ``pocketbase`` binary: set ``POCKETBASE_BIN`` to its path, put it on
|
||||
PATH, or drop it at ``/tmp/pb022/pocketbase``. The suite SKIPS (does not fail)
|
||||
when no binary is available so CI without the binary stays green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
|
||||
# These mirror the seed records created by the cvdiag_api_keys migration
|
||||
# (showcase/pocketbase/pb_migrations/1779990200_create_cvdiag_events.js).
|
||||
WRITER_EMAIL = "cvdiag-writer@keys.local"
|
||||
WRITER_PASS = "cvdiagwriterpass123"
|
||||
|
||||
# A superuser used ONLY to read rows back (rule-bypassing read); never the
|
||||
# write path under test.
|
||||
ADMIN_EMAIL = "cvdiag-py-acl@test.local"
|
||||
ADMIN_PASS = "cvdiagpyaclpass123"
|
||||
|
||||
# showcase/integrations/_shared/tests/ → repo root → showcase/pocketbase
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_MIGRATIONS_DIR = _REPO_ROOT / "showcase" / "pocketbase" / "pb_migrations"
|
||||
|
||||
|
||||
def _resolve_pb_binary() -> Optional[str]:
|
||||
explicit = os.environ.get("POCKETBASE_BIN")
|
||||
if explicit and Path(explicit).is_file():
|
||||
return explicit
|
||||
on_path = shutil.which("pocketbase")
|
||||
if on_path:
|
||||
return on_path
|
||||
if Path("/tmp/pb022/pocketbase").is_file():
|
||||
return "/tmp/pb022/pocketbase"
|
||||
return None
|
||||
|
||||
|
||||
_PB_BIN = _resolve_pb_binary()
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
_PB_BIN is None,
|
||||
reason="no pocketbase binary (set POCKETBASE_BIN, PATH, or /tmp/pb022/pocketbase)",
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_health(base: str, timeout_s: float = 15.0) -> None:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/api/health", timeout=1.0) as r:
|
||||
if r.status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, OSError):
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError("PocketBase did not become healthy in time")
|
||||
|
||||
|
||||
def _admin_token(base: str) -> str:
|
||||
body = json.dumps({"identity": ADMIN_EMAIL, "password": ADMIN_PASS}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/admins/auth-with-password",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5.0) as r:
|
||||
return json.loads(r.read())["token"]
|
||||
|
||||
|
||||
def _count_events(base: str, test_id: str) -> int:
|
||||
"""Count cvdiag_events rows for one test_id, reading AS SUPERUSER."""
|
||||
tok = _admin_token(base)
|
||||
flt = urllib.parse.quote(f'test_id="{test_id}"')
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/collections/cvdiag_events/records?filter={flt}",
|
||||
headers={"Authorization": tok},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5.0) as r:
|
||||
return json.loads(r.read())["totalItems"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live_pb():
|
||||
"""Boot a real PB with the production migrations; yield its base URL."""
|
||||
# skipif(_PB_BIN is None) guarantees a binary at runtime; narrow to a
|
||||
# concrete str so subprocess calls don't take a `str | None` arg.
|
||||
pb_bin = _PB_BIN
|
||||
assert pb_bin is not None
|
||||
data_dir = tempfile.mkdtemp(prefix="pb-cvdiag-py-")
|
||||
try:
|
||||
mig = subprocess.run(
|
||||
[
|
||||
pb_bin,
|
||||
"migrate",
|
||||
"up",
|
||||
f"--dir={data_dir}",
|
||||
f"--migrationsDir={_MIGRATIONS_DIR}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if mig.returncode != 0:
|
||||
raise RuntimeError(f"pb migrate up failed: {mig.stderr or mig.stdout}")
|
||||
admin = subprocess.run(
|
||||
[pb_bin, "admin", "create", ADMIN_EMAIL, ADMIN_PASS, f"--dir={data_dir}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if admin.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"pb admin create failed: {admin.stderr or admin.stdout}"
|
||||
)
|
||||
port = _free_port()
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
pb_bin,
|
||||
"serve",
|
||||
f"--http=127.0.0.1:{port}",
|
||||
f"--dir={data_dir}",
|
||||
f"--migrationsDir={_MIGRATIONS_DIR}",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(base)
|
||||
yield base
|
||||
finally:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
finally:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _flush_one(writer: CvdiagPbWriter, envelope: dict) -> None:
|
||||
"""Enqueue one envelope and let the daemon drain it (flush window 0.05s)."""
|
||||
writer.enqueue(envelope)
|
||||
# Give the worker a few flush windows to drain + POST + (re-)auth.
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _event(test_id: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"test_id": test_id,
|
||||
"trace_id": test_id,
|
||||
"span_id": "0000000000000001",
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.handle",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T00:00:00.000Z",
|
||||
"mono_ns": 1,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {},
|
||||
"metadata": {"src": "live-pb-test"},
|
||||
}
|
||||
|
||||
|
||||
def test_writer_authenticates_as_writer_role_and_persists(live_pb):
|
||||
"""GREEN: the writer auth-with-passwords as the writer role → CREATE 201 →
|
||||
the row is present in cvdiag_events (read back as superuser).
|
||||
|
||||
This is the fix for the inert ``X-Cvdiag-Writer-Key`` header: without
|
||||
auth-with-password → Bearer the CREATE 403s under the writer createRule.
|
||||
"""
|
||||
test_id = "0190a0c0-0000-7000-8000-00000000a001"
|
||||
# Pre-condition: no row yet.
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
|
||||
writer = CvdiagPbWriter(
|
||||
pb_url=live_pb,
|
||||
writer_key=WRITER_PASS, # CVDIAG_WRITER_KEY == the writer record password
|
||||
flush_window_s=0.05,
|
||||
)
|
||||
_flush_one(writer, _event(test_id))
|
||||
|
||||
# The CREATE went through the CREATE-only writer rule → row present.
|
||||
assert _count_events(live_pb, test_id) == 1
|
||||
|
||||
|
||||
def test_writer_with_wrong_password_degrades_to_noop(live_pb):
|
||||
"""A WRONG password must degrade to a no-op (auth fails, CREATE 401/403)
|
||||
WITHOUT crashing the daemon — never-throw preserved.
|
||||
"""
|
||||
test_id = "0190a0c0-0000-7000-8000-00000000a002"
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
|
||||
writer = CvdiagPbWriter(
|
||||
pb_url=live_pb,
|
||||
writer_key="totally-wrong-password",
|
||||
flush_window_s=0.05,
|
||||
)
|
||||
_flush_one(writer, _event(test_id))
|
||||
|
||||
# No row landed (auth failed) AND the daemon survived (no crash).
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
worker = writer._worker
|
||||
assert worker is not None and worker.is_alive(), (
|
||||
"daemon must survive an auth failure (never-throw)"
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* cvdiag-emitter.test.ts — vitest suite for the shared TS integration emitter
|
||||
* binding (plan unit L0-F). Asserts the four invariants the §6 PII/tier
|
||||
* contract requires of EVERY language emitter:
|
||||
* - schema conformance (re-exported envelope keys + UUIDv7 minters),
|
||||
* - tier gating (default vs verbose vs debug boundary inclusion),
|
||||
* - PII scrub (Bearer / sk- secrets removed from captured values),
|
||||
* - forbidden-header rejection (cf-ipcountry never captured),
|
||||
* - DEBUG-in-production refusal (fail-closed startup guard).
|
||||
*
|
||||
* These re-exercise the L0-A invariants THROUGH the binding so a regression in
|
||||
* the re-export wiring (wrong relative path, dropped symbol) fails here, not
|
||||
* silently in a downstream TS integration.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CvdiagEmitter,
|
||||
ENVELOPE_KEYS,
|
||||
EDGE_HEADER_DENYLIST,
|
||||
SCHEMA_VERSION,
|
||||
TEST_ID_REGEX,
|
||||
filterEdgeHeaders,
|
||||
isValidTestId,
|
||||
mintSpanId,
|
||||
mintTestId,
|
||||
scrubSecrets,
|
||||
validateEnvelope,
|
||||
} from "./cvdiag-emitter.js";
|
||||
import type { CvdiagEnvelope } from "./cvdiag-emitter.js";
|
||||
|
||||
describe("L0-F binding: re-exports resolve from the canonical schema", () => {
|
||||
it("re-exports SCHEMA_VERSION === 1", () => {
|
||||
expect(SCHEMA_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("re-exports the closed envelope key set and validator", () => {
|
||||
expect(ENVELOPE_KEYS).toContain("test_id");
|
||||
expect(ENVELOPE_KEYS).toContain("edge_headers");
|
||||
// A foreign top-level key is rejected (closed-world).
|
||||
const bad = validateEnvelope({ test_id: "x", attacker_key: 1 });
|
||||
expect(bad.ok).toBe(false);
|
||||
expect(bad.unknownKeys).toContain("attacker_key");
|
||||
});
|
||||
|
||||
it("re-exports the UUIDv7 minters + validator", () => {
|
||||
const id = mintTestId();
|
||||
expect(TEST_ID_REGEX.test(id)).toBe(true);
|
||||
expect(isValidTestId(id)).toBe(true);
|
||||
// A v4 UUID (version nibble 4) must be rejected.
|
||||
expect(isValidTestId("00000000-0000-4000-8000-000000000000")).toBe(false);
|
||||
// span_id is 16 lowercase hex chars.
|
||||
expect(mintSpanId()).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: schema conformance of an emitted envelope", () => {
|
||||
it("emits a closed-world envelope at the verbose tier", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
const env = emitter.emit({
|
||||
layer: "backend",
|
||||
boundary: "backend.agent.enter",
|
||||
slug: "langgraph-typescript",
|
||||
demo: "agentic_chat",
|
||||
outcome: "ok",
|
||||
metadata: { agent_name: "main", model_id: "gpt-4o" },
|
||||
}) as CvdiagEnvelope;
|
||||
expect(env).not.toBeNull();
|
||||
expect(env.schema_version).toBe(SCHEMA_VERSION);
|
||||
expect(isValidTestId(env.test_id)).toBe(true);
|
||||
expect(env.trace_id).toBe(env.test_id);
|
||||
expect(env.boundary).toBe("backend.agent.enter");
|
||||
// Every emitted key must be in the closed envelope key set.
|
||||
expect(validateEnvelope(env as unknown as Record<string, unknown>).ok).toBe(
|
||||
true,
|
||||
);
|
||||
// All 9 edge-header keys present (absent → null).
|
||||
expect(Object.keys(env.edge_headers).sort()).toEqual(
|
||||
[
|
||||
"cf-cache-status",
|
||||
"cf-mitigated",
|
||||
"cf-ray",
|
||||
"retry-after",
|
||||
"server",
|
||||
"via",
|
||||
"x-hikari-trace",
|
||||
"x-railway-edge",
|
||||
"x-railway-request-id",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops unknown metadata keys and stamps _metadata_dropped", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
const env = emitter.emit({
|
||||
layer: "backend",
|
||||
boundary: "backend.agent.enter",
|
||||
slug: "mastra",
|
||||
demo: "agentic_chat",
|
||||
outcome: "ok",
|
||||
metadata: { agent_name: "main", model_id: "gpt-4o", attacker: "x" },
|
||||
}) as CvdiagEnvelope;
|
||||
expect(env._metadata_dropped).toBe(true);
|
||||
expect(env.metadata).not.toHaveProperty("attacker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: tier gating", () => {
|
||||
it("default tier excludes a verbose-only boundary", () => {
|
||||
const emitter = new CvdiagEmitter({ env: {}, layer: "backend" });
|
||||
expect(emitter.tier).toBe("default");
|
||||
// backend.request.ingress is verbose+debug only (default:false).
|
||||
expect(emitter.shouldEmit("backend.request.ingress")).toBe(false);
|
||||
// backend.agent.enter is default:true.
|
||||
expect(emitter.shouldEmit("backend.agent.enter")).toBe(true);
|
||||
});
|
||||
|
||||
it("verbose tier includes verbose-only boundaries", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
expect(emitter.tier).toBe("verbose");
|
||||
expect(emitter.shouldEmit("backend.request.ingress")).toBe(true);
|
||||
});
|
||||
|
||||
it("accounting boundaries always emit regardless of tier", () => {
|
||||
const emitter = new CvdiagEmitter({ env: {}, layer: "backend" });
|
||||
expect(emitter.shouldEmit("cvdiag.queue_dropped")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: PII scrub (re-exported from edge-headers)", () => {
|
||||
it("scrubs Bearer tokens", () => {
|
||||
expect(scrubSecrets("auth: Bearer abc123def456")).toBe("auth: [REDACTED]");
|
||||
});
|
||||
|
||||
it("scrubs sk- provider keys", () => {
|
||||
expect(scrubSecrets("key sk-ABCDEFGHIJKLMNOP1234")).toBe("key [REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: forbidden edge-header rejection", () => {
|
||||
it("never captures cf-ipcountry even when present", () => {
|
||||
const filtered = filterEdgeHeaders({
|
||||
"cf-ray": "abc-iad",
|
||||
"cf-ipcountry": "US",
|
||||
"true-client-ip": "1.2.3.4",
|
||||
});
|
||||
expect(filtered["cf-ray"]).toBe("abc-iad");
|
||||
expect(filtered).not.toHaveProperty("cf-ipcountry");
|
||||
expect(filtered).not.toHaveProperty("true-client-ip");
|
||||
});
|
||||
|
||||
it("the deny list contains the cf-ip* family by exact match", () => {
|
||||
expect(EDGE_HEADER_DENYLIST).toContain("cf-ipcountry");
|
||||
expect(EDGE_HEADER_DENYLIST).toContain("cf-connecting-ip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: DEBUG fail-closed in production", () => {
|
||||
it("refuses DEBUG when env resolves to production", () => {
|
||||
expect(
|
||||
() =>
|
||||
new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: {
|
||||
SHOWCASE_ENV: "production",
|
||||
CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript",
|
||||
},
|
||||
}),
|
||||
).toThrow(/production/);
|
||||
});
|
||||
|
||||
it("refuses DEBUG when no env label resolves (unknown == prod)", () => {
|
||||
expect(
|
||||
() =>
|
||||
new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: { CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript" },
|
||||
}),
|
||||
).toThrow(/unresolved|production/);
|
||||
});
|
||||
|
||||
it("allows DEBUG in a non-prod env with an allow-list", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: {
|
||||
SHOWCASE_ENV: "staging",
|
||||
CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript",
|
||||
},
|
||||
});
|
||||
expect(emitter.tier).toBe("debug");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* cvdiag-emitter.ts — the SHARED TypeScript CVDIAG emitter binding that the
|
||||
* four TS-backed integrations import (langgraph-typescript, claude-sdk-typescript,
|
||||
* mastra, built-in-agent). Plan unit: L0-F. Spec: 2026-06-18-flap-observability
|
||||
* §5 (schema) + §6 (tiers / PII).
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
* SINGLE SOURCE OF TRUTH — this file RE-EXPORTS, it does NOT redefine.
|
||||
*
|
||||
* The canonical CVDIAG schema, edge-header allow/deny filter, PII scrub, and
|
||||
* the `CvdiagEmitter` (tier resolution, fail-closed DEBUG guard, byte caps,
|
||||
* bounded queue, UUIDv7 span/test minters) all live in L0-A under
|
||||
* `showcase/harness/src/cvdiag/`. This binding is a thin barrel that pulls
|
||||
* those symbols forward so the TS integrations have ONE import surface and so
|
||||
* a schema change in L0-A propagates here automatically (no duplicate enum to
|
||||
* drift). Re-exporting (not duplicating) is the whole point of the unit: the
|
||||
* §5 "single source of truth" / "CI lint fails on drift" policy is only
|
||||
* enforceable if every emitter shares the L0-A definitions.
|
||||
*
|
||||
* The relative path `../../../harness/src/cvdiag/` resolves as:
|
||||
* showcase/integrations/_shared/ts/ → ../../../harness/src/cvdiag/
|
||||
* ( _shared/ts → _shared → integrations → showcase )/harness/src/cvdiag
|
||||
* i.e. from this file up three levels to `showcase/`, then into `harness/`.
|
||||
* The `.js` extensions match L0-A's ESM (`"type": "module"`, bundler module
|
||||
* resolution) — at runtime under tsx / a bundler the `.js` specifier resolves
|
||||
* to the `.ts` source, exactly as the harness's own `index.ts` barrel does.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
* CROSS-CONTEXT RESOLUTION FOR L1-E (how a standalone TS integration's Docker
|
||||
* build sees these files):
|
||||
*
|
||||
* Each TS integration (langgraph-typescript, claude-sdk-typescript, mastra)
|
||||
* already vendors a sibling-directory `shared-tools/` into its build context
|
||||
* via a `COPY shared-tools/ ./shared-tools/` line in its Dockerfile (the
|
||||
* build context is the integration dir, so a sibling source tree is copied
|
||||
* in as a top-level dir, NOT imported across the repo). L1-E MUST use the
|
||||
* SAME mechanism for CVDIAG:
|
||||
*
|
||||
* 1. Stage `_shared/ts/cvdiag-emitter.ts` (this file, with the relative
|
||||
* re-exports flattened to point at a co-located copy of the L0-A
|
||||
* sources) AND the three L0-A sources it pulls from
|
||||
* (`schema.ts`, `edge-headers.ts`, `emit.ts`) into the integration's
|
||||
* build context — e.g. under `shared-tools/cvdiag/` or a dedicated
|
||||
* `_cvdiag/` dir — exactly as `shared-tools/` is copied today.
|
||||
* 2. Add `COPY shared-tools/cvdiag/ ./shared-tools/cvdiag/` (or the chosen
|
||||
* path) to each integration Dockerfile, mirroring the existing
|
||||
* `COPY shared-tools/ ./shared-tools/` line.
|
||||
* 3. The integration's CVDIAG wiring imports
|
||||
* `from "../../shared-tools/cvdiag/cvdiag-emitter"` (path per chosen
|
||||
* layout) rather than reaching across the monorepo — standalone npm
|
||||
* projects have no path alias back to `showcase/harness`.
|
||||
*
|
||||
* This is the TS analogue of L0-C's Python `_shared/cvdiag_bootstrap`: a
|
||||
* COPY-into-context staging step, NOT a workspace/path-alias import. Within
|
||||
* THIS slot (the harness/worktree) the relative `../../../harness/...`
|
||||
* re-export resolves directly so the vitest suite runs against the real L0-A
|
||||
* sources; L1-E performs the COPY-staging flatten when packaging each
|
||||
* integration. A `bin/showcase cvdiag stage-ts` helper (or the existing
|
||||
* build wrapper) is the natural home for the copy, so the staging is a
|
||||
* build step and not hand-maintained per integration.
|
||||
*/
|
||||
|
||||
// ── Schema (types, enums, validators, UUIDv7 regex) ─────────────────────────
|
||||
export {
|
||||
SCHEMA_VERSION,
|
||||
CVDIAG_LAYERS,
|
||||
CVDIAG_OUTCOMES,
|
||||
PROBE_BOUNDARIES,
|
||||
BACKEND_BOUNDARIES,
|
||||
AIMOCK_BOUNDARIES,
|
||||
CVDIAG_DATA_PLANE_BOUNDARIES,
|
||||
CVDIAG_ACCOUNTING_BOUNDARIES,
|
||||
CVDIAG_BOUNDARIES,
|
||||
EDGE_HEADER_KEYS,
|
||||
TERMINATION_KINDS,
|
||||
TEST_ID_REGEX,
|
||||
ENVELOPE_KEYS,
|
||||
BOUNDARY_METADATA_KEYS,
|
||||
isValidTestId,
|
||||
validateEnvelope,
|
||||
validateMetadata,
|
||||
} from "../../../harness/src/cvdiag/schema.js";
|
||||
|
||||
export type {
|
||||
CvdiagLayer,
|
||||
CvdiagOutcome,
|
||||
CvdiagDataPlaneBoundary,
|
||||
CvdiagAccountingBoundary,
|
||||
CvdiagBoundary,
|
||||
EdgeHeaders,
|
||||
EdgeHeaderKey,
|
||||
TerminationKind,
|
||||
CvdiagEnvelope,
|
||||
EnvelopeValidationResult,
|
||||
MetadataValidationResult,
|
||||
} from "../../../harness/src/cvdiag/schema.js";
|
||||
|
||||
// ── Edge-header allow/deny filter + PII scrub ───────────────────────────────
|
||||
export {
|
||||
EDGE_HEADER_ALLOWLIST,
|
||||
EDGE_HEADER_DENYLIST,
|
||||
BEARER_TOKEN_REGEX,
|
||||
SK_KEY_REGEX,
|
||||
URL_USERINFO_REGEX,
|
||||
SCRUB_REPLACEMENT,
|
||||
scrubSecrets,
|
||||
filterEdgeHeaders,
|
||||
} from "../../../harness/src/cvdiag/edge-headers.js";
|
||||
|
||||
// ── Emitter (tier resolution, fail-closed DEBUG, byte caps, span/id minters) ─
|
||||
export {
|
||||
CvdiagEmitter,
|
||||
BYTE_CAP_BY_TIER,
|
||||
QUEUE_CAP,
|
||||
DEBUG_MAX_WALLCLOCK_MS,
|
||||
DEBUG_MAX_EVENTS,
|
||||
FLUSH_WINDOW_MS,
|
||||
resolveEnvLabel,
|
||||
mintTestId,
|
||||
mintSpanId,
|
||||
} from "../../../harness/src/cvdiag/emit.js";
|
||||
|
||||
export type {
|
||||
CvdiagTier,
|
||||
CvdiagPbWriter,
|
||||
CvdiagEnv,
|
||||
CvdiagEmitterOptions,
|
||||
CvdiagEmitArgs,
|
||||
} from "../../../harness/src/cvdiag/emit.js";
|
||||
|
||||
// ── Concrete writer-role PB writer (plain fetch; auth-with-password→Bearer) ──
|
||||
export {
|
||||
CvdiagFetchPbWriter,
|
||||
createCvdiagFetchPbWriterFromEnv,
|
||||
} from "../../../harness/src/cvdiag/pb-writer-fetch.js";
|
||||
|
||||
export type {
|
||||
FetchLike,
|
||||
CvdiagFetchPbWriterOptions,
|
||||
} from "../../../harness/src/cvdiag/pb-writer-fetch.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["*.test.ts"],
|
||||
environment: "node",
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user