From 6cb42dc257b9e6ce2cfeda4c47d5be833a35d4d0 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Fri, 16 Oct 2026 16:32:43 +0100 Subject: [PATCH 1/7] Python SDK: shared config format, FastAPI server span enrichment - Load the shared agenttel.yml format (agenttel: root, kebab-case keys, operations map keyed by METHOD /route with optional [brackets], latency as duration string or ms number); legacy dialects load with a warning - AgentTelMiddleware (pure ASGI) resolves the route template and sets baselines and decisions on the active server span; the processor no longer depends on the pre-routing span name - instrument_fastapi() attaches to an existing SDK TracerProvider instead of replacing it, merges topology into its resource, and instruments the app with FastAPIInstrumentor when available - Decision attributes use agenttel.decision.* and topology uses on_call_channel as in attributes.json; old constants kept as aliases - Fix ConsumptionPattern.ASYNC value, add EscalationLevel.AUTO_RESOLVE - Fix fastapi-example (BatchSpanProcessor, valid enum values) - Add real-uvicorn integration test and config dialect tests Signed-off-by: Rohit Kumar --- agenttel-python/pyproject.toml | 3 +- agenttel-python/src/agenttel/__init__.py | 2 +- agenttel-python/src/agenttel/attributes.py | 27 +- .../src/agenttel/baseline/provider.py | 7 +- agenttel-python/src/agenttel/config.py | 325 +++++++++++++----- agenttel-python/src/agenttel/engine.py | 58 +++- agenttel-python/src/agenttel/enums.py | 3 +- .../src/agenttel/fastapi/config.py | 40 +-- .../src/agenttel/fastapi/decorators.py | 10 +- .../src/agenttel/fastapi/middleware.py | 142 ++++---- agenttel-python/src/agenttel/processor.py | 85 +++-- .../src/agenttel/topology/registry.py | 4 +- agenttel-python/tests/test_config.py | 138 ++++++++ .../tests/test_fastapi_integration.py | 112 ++++++ examples/fastapi-example/agenttel.yml | 12 +- examples/fastapi-example/app.py | 6 +- 16 files changed, 716 insertions(+), 258 deletions(-) create mode 100644 agenttel-python/tests/test_config.py create mode 100644 agenttel-python/tests/test_fastapi_integration.py diff --git a/agenttel-python/pyproject.toml b/agenttel-python/pyproject.toml index fcc96a4..ff7bc48 100644 --- a/agenttel-python/pyproject.toml +++ b/agenttel-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agenttel" -version = "0.3.0a1" +version = "0.4.0a1" description = "AgentTel Python SDK — AI-native telemetry for Python services" readme = "README.md" license = "Apache-2.0" @@ -39,6 +39,7 @@ dev = [ "pytest-asyncio>=0.21", "httpx>=0.24", "respx>=0.20", + "uvicorn>=0.24", ] [project.entry-points."opentelemetry_traces"] diff --git a/agenttel-python/src/agenttel/__init__.py b/agenttel-python/src/agenttel/__init__.py index d715410..310c677 100644 --- a/agenttel-python/src/agenttel/__init__.py +++ b/agenttel-python/src/agenttel/__init__.py @@ -10,4 +10,4 @@ "AgentTelSpanProcessor", ] -__version__ = "0.3.0-alpha" +__version__ = "0.4.0a1" diff --git a/agenttel-python/src/agenttel/attributes.py b/agenttel-python/src/agenttel/attributes.py index 019a756..d752b46 100644 --- a/agenttel-python/src/agenttel/attributes.py +++ b/agenttel-python/src/agenttel/attributes.py @@ -4,7 +4,9 @@ TOPOLOGY_TEAM = "agenttel.topology.team" TOPOLOGY_TIER = "agenttel.topology.tier" TOPOLOGY_DOMAIN = "agenttel.topology.domain" -TOPOLOGY_ONCALL_CHANNEL = "agenttel.topology.oncall_channel" +TOPOLOGY_ON_CALL_CHANNEL = "agenttel.topology.on_call_channel" +TOPOLOGY_ONCALL_CHANNEL = TOPOLOGY_ON_CALL_CHANNEL # deprecated alias +TOPOLOGY_REPO_URL = "agenttel.topology.repo_url" TOPOLOGY_SERVICE_VERSION = "agenttel.topology.service_version" # Dependency attributes @@ -23,13 +25,22 @@ CONSUMER_PATTERN = "agenttel.consumer.pattern" CONSUMER_SLA_LATENCY_MS = "agenttel.consumer.sla_latency_ms" -# Operation context attributes -OPERATION_RETRYABLE = "agenttel.operation.retryable" -OPERATION_IDEMPOTENT = "agenttel.operation.idempotent" -OPERATION_RUNBOOK_URL = "agenttel.operation.runbook_url" -OPERATION_FALLBACK_DESCRIPTION = "agenttel.operation.fallback_description" -OPERATION_ESCALATION_LEVEL = "agenttel.operation.escalation_level" -OPERATION_SAFE_TO_RESTART = "agenttel.operation.safe_to_restart" +# Decision attributes (operation context an agent needs to act safely) +DECISION_RETRYABLE = "agenttel.decision.retryable" +DECISION_IDEMPOTENT = "agenttel.decision.idempotent" +DECISION_RUNBOOK_URL = "agenttel.decision.runbook_url" +DECISION_FALLBACK_AVAILABLE = "agenttel.decision.fallback_available" +DECISION_FALLBACK_DESCRIPTION = "agenttel.decision.fallback_description" +DECISION_ESCALATION_LEVEL = "agenttel.decision.escalation_level" +DECISION_SAFE_TO_RESTART = "agenttel.decision.safe_to_restart" + +# Deprecated aliases (pre-0.4.0 names); values now match attributes.json +OPERATION_RETRYABLE = DECISION_RETRYABLE +OPERATION_IDEMPOTENT = DECISION_IDEMPOTENT +OPERATION_RUNBOOK_URL = DECISION_RUNBOOK_URL +OPERATION_FALLBACK_DESCRIPTION = DECISION_FALLBACK_DESCRIPTION +OPERATION_ESCALATION_LEVEL = DECISION_ESCALATION_LEVEL +OPERATION_SAFE_TO_RESTART = DECISION_SAFE_TO_RESTART OPERATION_PROFILE = "agenttel.operation.profile" # Baseline attributes diff --git a/agenttel-python/src/agenttel/baseline/provider.py b/agenttel-python/src/agenttel/baseline/provider.py index 405cc9b..28cc831 100644 --- a/agenttel-python/src/agenttel/baseline/provider.py +++ b/agenttel-python/src/agenttel/baseline/provider.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod +from agenttel.config import normalize_operation_name from agenttel.models import OperationBaseline @@ -29,13 +30,13 @@ def __init__(self) -> None: def register(self, baseline: OperationBaseline) -> None: """Register a static baseline for an operation.""" - self._baselines[baseline.operation_name] = baseline + self._baselines[normalize_operation_name(baseline.operation_name)] = baseline def get_baseline(self, operation_name: str) -> OperationBaseline | None: - return self._baselines.get(operation_name) + return self._baselines.get(normalize_operation_name(operation_name)) def has_baseline(self, operation_name: str) -> bool: - return operation_name in self._baselines + return normalize_operation_name(operation_name) in self._baselines def all_baselines(self) -> dict[str, OperationBaseline]: """Return all registered baselines.""" diff --git a/agenttel-python/src/agenttel/config.py b/agenttel-python/src/agenttel/config.py index ce841bb..609581a 100644 --- a/agenttel-python/src/agenttel/config.py +++ b/agenttel-python/src/agenttel/config.py @@ -1,11 +1,35 @@ -"""AgentTel configuration loader.""" +"""AgentTel configuration loader. + +The canonical format is shared by every AgentTel SDK (JVM, Go, Node.js, Python):: + + agenttel: + topology: + team: payments-platform + tier: critical + on-call-channel: "#payments-oncall" + operations: + "[POST /api/payments]": # brackets optional + expected-latency-p50: 45ms # duration string, or a number in ms + expected-latency-p99: 200ms + expected-error-rate: 0.001 + retryable: true + runbook-url: https://wiki.example.com/runbooks/payments + +Older dialects (no ``agenttel:`` root, snake_case or camelCase keys, and the +``operations`` list used by the Node.js SDK before 0.4.0) are still accepted and +logged as deprecated. +""" from __future__ import annotations +import logging import os +import re from pathlib import Path +from typing import Any + import yaml -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict from agenttel.enums import ( ConsumptionPattern, @@ -16,110 +40,150 @@ SloType, ) +logger = logging.getLogger("agenttel.config") + + +def _kebab(name: str) -> str: + return name.replace("_", "-") + + +class _ConfigModel(BaseModel): + """Base model: kebab-case keys in YAML, snake_case attributes in Python.""" + + model_config = ConfigDict(alias_generator=_kebab, populate_by_name=True) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +_DURATION_RE = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*(ms|s|m)?\s*$", re.IGNORECASE) +_PATH_PARAM_RE = re.compile(r"(^|/):([A-Za-z_][A-Za-z0-9_]*)") + + +def parse_duration_ms(value: str | int | float | None) -> float | None: + """Parse a duration ("45ms", "1.5s", "2m") or a plain number (milliseconds).""" + if value is None or value == "": + return None + if isinstance(value, bool): + raise ValueError(f"invalid duration: {value!r}") + if isinstance(value, (int, float)): + return float(value) + m = _DURATION_RE.match(str(value)) + if not m: + raise ValueError(f"invalid duration: {value!r} (expected e.g. '45ms' or 45)") + number, unit = float(m.group(1)), (m.group(2) or "ms").lower() + return number * {"ms": 1.0, "s": 1000.0, "m": 60000.0}[unit] + + +def normalize_operation_name(name: str) -> str: + """Normalize an operation key to the canonical "METHOD /route" form. + + Strips Spring-style ``[brackets]``, collapses whitespace, upper-cases the HTTP + method, and rewrites ``:param`` path segments to ``{param}`` so that Express, + Gin, FastAPI and net/http route templates all share one key. + """ + n = name.strip() + if n.startswith("[") and n.endswith("]"): + n = n[1:-1].strip() + parts = n.split(None, 1) + if len(parts) == 2 and parts[1].startswith("/"): + route = _PATH_PARAM_RE.sub(r"\1{\2}", parts[1]) + n = f"{parts[0].upper()} {route}" + return n + -class TopologyConfig(BaseModel): +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class TopologyConfig(_ConfigModel): """Service topology configuration.""" + service_name: str = "" team: str = "" tier: ServiceTier = ServiceTier.STANDARD domain: str = "" on_call_channel: str = "" + repo_url: str = "" service_version: str = "" - model_config = {"populate_by_name": True} - -class DependencyConfig(BaseModel): +class DependencyConfig(_ConfigModel): """Dependency configuration.""" name: str type: DependencyType = DependencyType.INTERNAL_SERVICE criticality: DependencyCriticality = DependencyCriticality.REQUIRED protocol: str | None = None - timeout_ms: int = Field(default=5000, alias="timeout-ms") - circuit_breaker: bool = Field(default=False, alias="circuit-breaker") + timeout_ms: int = 5000 + circuit_breaker: bool = False fallback: str | None = None - health_endpoint: str | None = Field(default=None, alias="health-endpoint") - - model_config = {"populate_by_name": True} + health_endpoint: str | None = None -class ConsumerConfig(BaseModel): +class ConsumerConfig(_ConfigModel): """Consumer configuration.""" name: str pattern: ConsumptionPattern = ConsumptionPattern.SYNC - sla_latency_ms: int | None = Field(default=None, alias="sla-latency-ms") + sla_latency_ms: int | None = None - model_config = {"populate_by_name": True} - -class ProfileConfig(BaseModel): - """Operation profile configuration.""" +class ProfileConfig(_ConfigModel): + """Operation profile configuration (shared decision defaults).""" retryable: bool = False idempotent: bool = False - escalation_level: EscalationLevel = Field( - default=EscalationLevel.NOTIFY_TEAM, alias="escalation-level" - ) - safe_to_restart: bool = Field(default=True, alias="safe-to-restart") - fallback_description: str | None = Field(default=None, alias="fallback-description") - - model_config = {"populate_by_name": True} + runbook_url: str | None = None + escalation_level: EscalationLevel = EscalationLevel.AUTO_RESOLVE + safe_to_restart: bool = True + fallback_description: str | None = None -class OperationConfig(BaseModel): - """Per-operation configuration.""" +class OperationConfig(_ConfigModel): + """Per-operation configuration. Explicit values override the profile.""" profile: str | None = None - expected_latency_p50: str | None = Field(default=None, alias="expected-latency-p50") - expected_latency_p99: str | None = Field(default=None, alias="expected-latency-p99") - expected_error_rate: float | None = Field(default=None, alias="expected-error-rate") - runbook_url: str | None = Field(default=None, alias="runbook-url") + expected_latency_p50: str | float | None = None + expected_latency_p99: str | float | None = None + expected_error_rate: float | None = None retryable: bool | None = None idempotent: bool | None = None + runbook_url: str | None = None + escalation_level: EscalationLevel | None = None + safe_to_restart: bool | None = None + fallback_description: str | None = None - model_config = {"populate_by_name": True} - - def parse_latency_ms(self, value: str | None) -> float | None: - """Parse latency string like '80ms' to float.""" - if value is None: - return None - v = value.strip().lower() - if v.endswith("ms"): - return float(v[:-2]) - if v.endswith("s"): - return float(v[:-1]) * 1000 - return float(v) + def parse_latency_ms(self, value: str | float | None) -> float | None: + """Parse latency like '80ms', '1s' or 80 to float milliseconds.""" + return parse_duration_ms(value) @property def latency_p50_ms(self) -> float | None: - return self.parse_latency_ms(self.expected_latency_p50) + return parse_duration_ms(self.expected_latency_p50) @property def latency_p99_ms(self) -> float | None: - return self.parse_latency_ms(self.expected_latency_p99) + return parse_duration_ms(self.expected_latency_p99) -class BaselineConfig(BaseModel): +class BaselineConfig(_ConfigModel): """Baseline configuration.""" - rolling_window_size: int = Field(default=1000, alias="rolling-window-size") - rolling_min_samples: int = Field(default=10, alias="rolling-min-samples") - - model_config = {"populate_by_name": True} + rolling_window_size: int = 1000 + rolling_min_samples: int = 10 -class AnomalyDetectionConfig(BaseModel): +class AnomalyDetectionConfig(_ConfigModel): """Anomaly detection configuration.""" enabled: bool = True - z_score_threshold: float = Field(default=3.0, alias="z-score-threshold") + z_score_threshold: float = 3.0 - model_config = {"populate_by_name": True} - -class SloConfig(BaseModel): +class SloConfig(_ConfigModel): """SLO configuration.""" name: str = "" @@ -127,18 +191,16 @@ class SloConfig(BaseModel): type: SloType = SloType.AVAILABILITY -class DeploymentConfig(BaseModel): +class DeploymentConfig(_ConfigModel): """Deployment configuration.""" - emit_on_startup: bool = Field(default=True, alias="emit-on-startup") - deployment_id: str | None = Field(default=None, alias="deployment-id") + emit_on_startup: bool = True + deployment_id: str | None = None version: str | None = None environment: str = "development" - model_config = {"populate_by_name": True} - -class McpConfig(BaseModel): +class McpConfig(_ConfigModel): """MCP server configuration.""" enabled: bool = False @@ -146,13 +208,13 @@ class McpConfig(BaseModel): host: str = "0.0.0.0" -class AgentRoleConfig(BaseModel): +class AgentRoleConfig(_ConfigModel): """Agent role permissions configuration.""" tools: list[str] = [] -class AgentTelConfig(BaseModel): +class AgentTelConfig(_ConfigModel): """Root AgentTel configuration.""" enabled: bool = True @@ -162,36 +224,143 @@ class AgentTelConfig(BaseModel): profiles: dict[str, ProfileConfig] = {} operations: dict[str, OperationConfig] = {} baselines: BaselineConfig = BaselineConfig() - anomaly_detection: AnomalyDetectionConfig = Field( - default=AnomalyDetectionConfig(), alias="anomaly-detection" - ) + anomaly_detection: AnomalyDetectionConfig = AnomalyDetectionConfig() slo: dict[str, SloConfig] = {} deployment: DeploymentConfig = DeploymentConfig() mcp: McpConfig = McpConfig() - agent_roles: dict[str, AgentRoleConfig] = Field( - default={}, alias="agent-roles" - ) + agent_roles: dict[str, AgentRoleConfig] = {} + + def get_operation(self, name: str) -> OperationConfig | None: + """Look up an operation by name in any accepted key form.""" + key = normalize_operation_name(name) + op = self.operations.get(key) + if op is None: # operations assigned after construction may be unnormalized + op = next( + (v for k, v in self.operations.items() if normalize_operation_name(k) == key), + None, + ) + return op - model_config = {"populate_by_name": True} + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> AgentTelConfig: + """Build configuration from a parsed YAML/JSON document.""" + return cls.model_validate(normalize_raw_config(data or {})) @classmethod def from_yaml(cls, path: str | Path) -> AgentTelConfig: - """Load configuration from a YAML file.""" + """Load configuration from a YAML file. A missing file yields defaults.""" path = Path(path) if not path.exists(): return cls() - with open(path) as f: data = yaml.safe_load(f) or {} - - # Support both top-level and nested under 'agenttel' key - if "agenttel" in data: - data = data["agenttel"] - - return cls.model_validate(data) + return cls.from_dict(data) @classmethod def from_env(cls) -> AgentTelConfig: - """Load configuration from environment or default paths.""" + """Load configuration from $AGENTTEL_CONFIG or ./agenttel.yml.""" config_path = os.environ.get("AGENTTEL_CONFIG", "agenttel.yml") return cls.from_yaml(config_path) + + def model_post_init(self, __context: Any) -> None: + # Operations set programmatically get the same key normalization as YAML. + if any(normalize_operation_name(k) != k for k in self.operations): + self.operations = {normalize_operation_name(k): v for k, v in self.operations.items()} + + +# --------------------------------------------------------------------------- +# Raw document normalization (shared dialect + backward compatibility) +# --------------------------------------------------------------------------- + +# Sections whose keys are user-chosen names, not schema keys. +_NAMED_MAPS = {"operations", "profiles", "slo", "agent-roles"} + +# Legacy key spellings that are not a plain case conversion. +_LEGACY_OPERATION_KEYS = { + "baseline-latency-p50-ms": "expected-latency-p50", + "baseline-latency-p99-ms": "expected-latency-p99", + "baseline-error-rate": "expected-error-rate", +} + + +def _to_kebab(key: str) -> str: + s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", key) + return s.replace("_", "-").lower() + + +_ENTRY = object() # marks the value of a user-named map entry + + +def _kebab_keys(value: Any, section: Any, legacy: set[str]) -> Any: + """Recursively convert schema keys to kebab-case; keep user-chosen names.""" + if isinstance(value, list): + return [_kebab_keys(v, None, legacy) for v in value] + if not isinstance(value, dict): + return value + out: dict[str, Any] = {} + for k, v in value.items(): + key = str(k) + if section in _NAMED_MAPS: + new_key = normalize_operation_name(key) if section == "operations" else key + out[new_key] = _kebab_keys(v, _ENTRY, legacy) + continue + new_key = _to_kebab(key) + if new_key != key: + legacy.add(key) + out[new_key] = _kebab_keys(v, new_key, legacy) + return out + + +def normalize_raw_config(data: dict[str, Any]) -> dict[str, Any]: + """Convert any supported dialect into the canonical kebab-case structure.""" + if "agenttel" in data and isinstance(data["agenttel"], dict): + data = data["agenttel"] + elif data: + logger.warning( + "agenttel config without a top-level 'agenttel:' key is deprecated; " + "nest the configuration under 'agenttel:'" + ) + + legacy: set[str] = set() + data = _kebab_keys(data, None, legacy) + + # Node.js < 0.4.0 flat keys. + for old, new in (("rolling-window-size", "baselines"), ("rolling-min-samples", "baselines")): + if old in data: + data.setdefault(new, {})[old] = data.pop(old) + legacy.add(old) + if "anomaly-zscore-threshold" in data: + data.setdefault("anomaly-detection", {})["z-score-threshold"] = data.pop( + "anomaly-zscore-threshold" + ) + legacy.add("anomaly_zscore_threshold") + + ops = data.get("operations") + if isinstance(ops, list): + logger.warning( + "agenttel 'operations' as a list is deprecated; use a map keyed by " + "\"METHOD /route\" (see the AgentTel configuration docs)" + ) + converted: dict[str, Any] = {} + for item in ops: + item = dict(item or {}) + name = item.pop("name", None) + if not name: + continue + converted[normalize_operation_name(str(name))] = item + data["operations"] = ops = converted + if isinstance(ops, dict): + for op in ops.values(): + if isinstance(op, dict): + for old, new in _LEGACY_OPERATION_KEYS.items(): + if old in op: + op.setdefault(new, op.pop(old)) + legacy.add(old) + + if legacy: + logger.warning( + "agenttel config uses deprecated key spellings %s; use kebab-case " + "(e.g. 'on-call-channel', 'expected-latency-p50')", + sorted(legacy), + ) + return data diff --git a/agenttel-python/src/agenttel/engine.py b/agenttel-python/src/agenttel/engine.py index d1b6677..ec1ae33 100644 --- a/agenttel-python/src/agenttel/engine.py +++ b/agenttel-python/src/agenttel/engine.py @@ -116,15 +116,30 @@ def causality_tracker(self) -> CausalityTracker: def event_emitter(self) -> AgentTelEventEmitter: return self._event_emitter - def install(self, tracer_provider: TracerProvider | None = None) -> None: - """Install the AgentTel span processor into the OTel SDK.""" + def install(self, tracer_provider: TracerProvider | None = None) -> TracerProvider: + """Install the AgentTel span processor into the OTel SDK. + + Uses ``tracer_provider`` if given, otherwise the globally registered SDK + ``TracerProvider`` if the application already set one. Only when no SDK + provider exists is a new one created (with topology on its resource) and + registered globally. Installing twice is a no-op. + """ if tracer_provider is None: - # Create a new provider with topology resource attributes - resource = Resource.create(self._topology.get_topology_attributes()) - tracer_provider = TracerProvider(resource=resource) - trace.set_tracer_provider(tracer_provider) - + current = trace.get_tracer_provider() + if isinstance(current, TracerProvider): + tracer_provider = current + else: + attrs: dict[str, str] = dict(self._topology.get_topology_attributes()) + if self._config.topology.service_name: + attrs.setdefault("service.name", self._config.topology.service_name) + tracer_provider = TracerProvider(resource=Resource.create(attrs)) + trace.set_tracer_provider(tracer_provider) + + if getattr(tracer_provider, "_agenttel_installed", None) is self: + return tracer_provider + self._merge_topology_into_resource(tracer_provider) tracer_provider.add_span_processor(self._processor) + tracer_provider._agenttel_installed = self # type: ignore[attr-defined] # Emit deployment event if configured if self._config.deployment.emit_on_startup: @@ -133,17 +148,40 @@ def install(self, tracer_provider: TracerProvider | None = None) -> None: environment=self._config.deployment.environment, deployment_id=self._config.deployment.deployment_id, ) + return tracer_provider + + def _merge_topology_into_resource(self, provider: TracerProvider) -> None: + """Best effort: add topology to an existing provider's resource. + + Tracers created after this call carry the merged resource. Span-level + topology attributes are always set by the processor regardless. + """ + resource = getattr(provider, "_resource", None) + if not isinstance(resource, Resource): + return + missing = { + k: v + for k, v in self._topology.get_topology_attributes().items() + if k not in resource.attributes + } + if not missing: + return + update = getattr(provider, "_update_resource", None) + if callable(update): # opentelemetry-sdk >= 1.39 also updates existing tracers + update(Resource(missing)) + else: + provider._resource = resource.merge(Resource(missing)) # type: ignore[attr-defined] def _register_static_baselines(self) -> None: """Register static baselines from operation configs.""" for op_name, op_config in self._config.operations.items(): p50 = op_config.latency_p50_ms p99 = op_config.latency_p99_ms - if p50 is not None and p99 is not None: + if p50 is not None or p99 is not None or op_config.expected_error_rate is not None: baseline = OperationBaseline( operation_name=op_name, - latency_p50_ms=p50, - latency_p99_ms=p99, + latency_p50_ms=p50 or 0.0, + latency_p99_ms=p99 or 0.0, error_rate=op_config.expected_error_rate or 0.0, source=BaselineSource.STATIC, ) diff --git a/agenttel-python/src/agenttel/enums.py b/agenttel-python/src/agenttel/enums.py index 136f0d2..5fdbb91 100644 --- a/agenttel-python/src/agenttel/enums.py +++ b/agenttel-python/src/agenttel/enums.py @@ -83,6 +83,7 @@ class ImpactScope(StrEnum): class EscalationLevel(StrEnum): + AUTO_RESOLVE = "auto_resolve" NOTIFY_TEAM = "notify_team" PAGE_ONCALL = "page_oncall" PAGE_MANAGER = "page_manager" @@ -98,7 +99,7 @@ class AgentRole(StrEnum): class ConsumptionPattern(StrEnum): SYNC = "sync" - ASYNC = "async_" + ASYNC = "async" BATCH = "batch" STREAMING = "streaming" diff --git a/agenttel-python/src/agenttel/fastapi/config.py b/agenttel-python/src/agenttel/fastapi/config.py index bdbd489..c2eea17 100644 --- a/agenttel-python/src/agenttel/fastapi/config.py +++ b/agenttel-python/src/agenttel/fastapi/config.py @@ -5,10 +5,6 @@ from pathlib import Path from typing import Any -from opentelemetry import trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider - from agenttel.config import AgentTelConfig from agenttel.engine import AgentTelEngine @@ -20,8 +16,8 @@ def auto_configure( ) -> AgentTelEngine: """Auto-configure AgentTel for a FastAPI application. - Loads agenttel.yml, configures all components, registers the - AgentTelSpanProcessor, and sets up topology as OTel resource attributes. + Equivalent to :func:`agenttel.fastapi.instrument_fastapi`; ``service_name`` + is used as ``service.name`` if AgentTel has to create the TracerProvider. Args: app: FastAPI application instance. @@ -31,33 +27,9 @@ def auto_configure( Returns: Configured AgentTelEngine. """ - config = AgentTelConfig.from_yaml(config_path) - engine = AgentTelEngine(config) + from agenttel.fastapi.middleware import instrument_fastapi - # Build resource attributes - resource_attrs: dict[str, str] = {} + config = AgentTelConfig.from_yaml(config_path) if service_name: - resource_attrs["service.name"] = service_name - resource_attrs.update(engine.topology.get_topology_attributes()) - - resource = Resource.create(resource_attrs) - provider = TracerProvider(resource=resource) - - # Register AgentTel processor - provider.add_span_processor(engine.processor) - trace.set_tracer_provider(provider) - - # Add middleware - from agenttel.fastapi.middleware import AgentTelMiddleware - - app.add_middleware(AgentTelMiddleware, engine=engine) - - # Emit deployment event - if config.deployment.emit_on_startup: - engine.event_emitter.emit_deployment( - version=config.deployment.version, - environment=config.deployment.environment, - deployment_id=config.deployment.deployment_id, - ) - - return engine + config.topology.service_name = service_name + return instrument_fastapi(app, engine=AgentTelEngine(config)) diff --git a/agenttel-python/src/agenttel/fastapi/decorators.py b/agenttel-python/src/agenttel/fastapi/decorators.py index 2be3017..2236611 100644 --- a/agenttel-python/src/agenttel/fastapi/decorators.py +++ b/agenttel-python/src/agenttel/fastapi/decorators.py @@ -50,13 +50,13 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) def _set_attributes(span: Any) -> None: - span.set_attribute(attr.OPERATION_RETRYABLE, retryable) - span.set_attribute(attr.OPERATION_IDEMPOTENT, idempotent) - span.set_attribute(attr.OPERATION_SAFE_TO_RESTART, safe_to_restart) + span.set_attribute(attr.DECISION_RETRYABLE, retryable) + span.set_attribute(attr.DECISION_IDEMPOTENT, idempotent) + span.set_attribute(attr.DECISION_SAFE_TO_RESTART, safe_to_restart) if runbook_url: - span.set_attribute(attr.OPERATION_RUNBOOK_URL, runbook_url) + span.set_attribute(attr.DECISION_RUNBOOK_URL, runbook_url) if escalation_level: - span.set_attribute(attr.OPERATION_ESCALATION_LEVEL, escalation_level) + span.set_attribute(attr.DECISION_ESCALATION_LEVEL, escalation_level) if profile: span.set_attribute(attr.OPERATION_PROFILE, profile) diff --git a/agenttel-python/src/agenttel/fastapi/middleware.py b/agenttel-python/src/agenttel/fastapi/middleware.py index f94ba99..5addaaf 100644 --- a/agenttel-python/src/agenttel/fastapi/middleware.py +++ b/agenttel-python/src/agenttel/fastapi/middleware.py @@ -2,30 +2,39 @@ from __future__ import annotations +import logging from typing import Any -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Match - from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from starlette.routing import Match -from agenttel import attributes as attr from agenttel.engine import AgentTelEngine +logger = logging.getLogger("agenttel.fastapi") + def instrument_fastapi( app: Any, config_path: str = "agenttel.yml", engine: AgentTelEngine | None = None, + tracer_provider: TracerProvider | None = None, + instrument_http: bool = True, ) -> AgentTelEngine: """One-line integration for FastAPI apps. + Loads ``agenttel.yml`` (unless ``engine`` is given), attaches the AgentTel span + processor to ``tracer_provider`` -- or to the application's already registered + SDK ``TracerProvider``, creating one only if none exists -- adds the AgentTel + middleware, and (if ``opentelemetry-instrumentation-fastapi`` is installed and + the app is not instrumented yet) instruments the app so server spans exist. + Args: app: FastAPI application instance. config_path: Path to agenttel.yml configuration file. engine: Optional pre-configured engine. If None, one is created from config. + tracer_provider: Optional SDK TracerProvider to attach to. + instrument_http: Call ``FastAPIInstrumentor.instrument_app`` when available. Returns: The AgentTelEngine instance. @@ -33,82 +42,71 @@ def instrument_fastapi( if engine is None: engine = AgentTelEngine.from_config(config_path) - engine.install() + provider = engine.install(tracer_provider) + # Added before the OTel middleware so that, on older instrumentation versions + # that use add_middleware, the server span is already active when we run. app.add_middleware(AgentTelMiddleware, engine=engine) + + if instrument_http and not getattr(app, "_is_instrumented_by_opentelemetry", False): + try: + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + except ImportError: + logger.info( + "opentelemetry-instrumentation-fastapi not installed; AgentTel will " + "enrich server spans created by your own instrumentation" + ) + else: + FastAPIInstrumentor.instrument_app(app, tracer_provider=provider) return engine -class AgentTelMiddleware(BaseHTTPMiddleware): - """Starlette middleware that enriches spans with AgentTel attributes.""" +class AgentTelMiddleware: + """ASGI middleware that enriches the active server span with AgentTel attributes. + + HTTP instrumentations name the server span before routing (e.g. just "POST"), + so the span processor cannot match it to a configured operation at start. This + middleware resolves the route template and sets baseline and decision + attributes for "METHOD /route" on the server span. + """ def __init__(self, app: Any, engine: AgentTelEngine) -> None: - super().__init__(app) + self.app = app self._engine = engine - async def dispatch( - self, request: Request, call_next: RequestResponseEndpoint - ) -> Response: - """Process request and enrich the active span.""" + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope.get("type") == "http": + try: + self._enrich(scope) + except Exception: # never break the request because of telemetry + logger.debug("AgentTel span enrichment failed", exc_info=True) + await self.app(scope, receive, send) + + def _enrich(self, scope: dict) -> None: span = trace.get_current_span() + if not span.is_recording(): + return + + route_template = self._resolve_route(scope) + if route_template: + span.set_attribute("http.route", route_template) - if span and span.is_recording(): - # Resolve route template from FastAPI router - route_template = self._resolve_route(request) - if route_template: - span.set_attribute("http.route", route_template) - - # Set topology attributes - topo_attrs = self._engine.topology.get_topology_attributes() - for key, value in topo_attrs.items(): - span.set_attribute(key, value) - - # Resolve and set operation context - method = request.method - if route_template: - operation_name = f"[{method} {route_template}]" - op_config = self._engine.config.operations.get(operation_name) - if op_config: - self._set_operation_attributes(span, operation_name, op_config) - - response = await call_next(request) - return response - - def _resolve_route(self, request: Request) -> str | None: + for key, value in self._engine.topology.get_topology_attributes().items(): + span.set_attribute(key, value) + + method = scope.get("method", "") + path = route_template or scope.get("path", "") + if method and path: + self._engine.processor.enrich_operation(span, f"{method} {path}") + + @staticmethod + def _resolve_route(scope: dict) -> str | None: """Resolve the route template from the FastAPI/Starlette router.""" - if not hasattr(request, "app"): - return None - - app = request.app - # Walk through Starlette app to find routes - routes = getattr(app, "routes", []) - for route in routes: - match, _ = route.matches(request.scope) + app = scope.get("app") + for route in getattr(app, "routes", []) or []: + try: + match, _ = route.matches(scope) + except Exception: + continue if match == Match.FULL: return getattr(route, "path", None) - return request.url.path - - def _set_operation_attributes( - self, span: Any, operation_name: str, op_config: Any - ) -> None: - """Set operation-level attributes from config.""" - config = self._engine.config - profile = None - if op_config.profile and op_config.profile in config.profiles: - profile = config.profiles[op_config.profile] - - if profile: - span.set_attribute(attr.OPERATION_RETRYABLE, profile.retryable) - span.set_attribute(attr.OPERATION_IDEMPOTENT, profile.idempotent) - span.set_attribute(attr.OPERATION_SAFE_TO_RESTART, profile.safe_to_restart) - span.set_attribute( - attr.OPERATION_ESCALATION_LEVEL, profile.escalation_level.value - ) - if profile.fallback_description: - span.set_attribute( - attr.OPERATION_FALLBACK_DESCRIPTION, profile.fallback_description - ) - - if op_config.runbook_url: - span.set_attribute(attr.OPERATION_RUNBOOK_URL, op_config.runbook_url) - if op_config.profile: - span.set_attribute(attr.OPERATION_PROFILE, op_config.profile) + return None diff --git a/agenttel-python/src/agenttel/processor.py b/agenttel-python/src/agenttel/processor.py index 2814db5..c7f431f 100644 --- a/agenttel-python/src/agenttel/processor.py +++ b/agenttel-python/src/agenttel/processor.py @@ -10,8 +10,8 @@ from agenttel.baseline.composite import CompositeBaselineProvider from agenttel.baseline.rolling import RollingBaselineProvider from agenttel.causality.tracker import CausalityTracker -from agenttel.config import AgentTelConfig -from agenttel.enums import AlertLevel +from agenttel.config import AgentTelConfig, normalize_operation_name +from agenttel.enums import AlertLevel, EscalationLevel from agenttel.error.classifier import ErrorClassifier from agenttel.events import AgentTelEventEmitter from agenttel.models import OperationBaseline, OperationContext @@ -49,28 +49,36 @@ def __init__( self._event_emitter = event_emitter def on_start(self, span: Span, parent_context: context.Context | None = None) -> None: - """Enrich span on start with baseline and context attributes.""" - if not span.is_recording(): - return - - operation_name = self._resolve_operation_name(span) - if not operation_name: + """Enrich span on start with topology, decision and baseline attributes.""" + if not self._config.enabled or not span.is_recording(): return # Attach topology attributes - topo_attrs = self._topology.get_topology_attributes() - for key, value in topo_attrs.items(): + for key, value in self._topology.get_topology_attributes().items(): span.set_attribute(key, value) - # Attach operation context - op_context = self._resolve_operation_context(operation_name) + operation_name = self._resolve_operation_name(span) + if operation_name: + self.enrich_operation(span, operation_name) + + def enrich_operation(self, span: Span, operation_name: str) -> bool: + """Set decision and baseline attributes for ``operation_name`` on ``span``. + + Framework integrations call this once the route template is known (HTTP + server spans are usually named before routing, e.g. just "POST"). + Returns True if the operation is configured or has a baseline. + """ + operation_name = normalize_operation_name(operation_name) + found = False + op_context = self.resolve_operation_context(operation_name) if op_context: self._set_operation_context(span, op_context) - - # Attach baseline attributes + found = True baseline = self._baseline_provider.get_baseline(operation_name) if baseline: self._set_baseline_attributes(span, baseline) + found = True + return found def on_end(self, span: ReadableSpan) -> None: """Process span on end: update rolling baselines, detect anomalies, check SLO.""" @@ -127,7 +135,8 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: def _resolve_operation_name(self, span: Span) -> str | None: """Resolve operation name from span.""" - return span.name if hasattr(span, "name") else None + name = getattr(span, "name", None) + return normalize_operation_name(name) if name else None def _resolve_operation_name_from_readable(self, span: ReadableSpan) -> str | None: """Resolve operation name from a readable span.""" @@ -135,42 +144,48 @@ def _resolve_operation_name_from_readable(self, span: ReadableSpan) -> str | Non route = span.attributes.get("http.route") if span.attributes else None method = span.attributes.get("http.request.method") or span.attributes.get("http.method") if span.attributes else None if route and method: - return f"[{method} {route}]" - return span.name + return normalize_operation_name(f"{method} {route}") + return normalize_operation_name(span.name) if span.name else None - def _resolve_operation_context(self, operation_name: str) -> OperationContext | None: - """Resolve operation context from config.""" - op_config = self._config.operations.get(operation_name) + def resolve_operation_context(self, operation_name: str) -> OperationContext | None: + """Resolve decision context: explicit operation values > profile > defaults.""" + op_config = self._config.get_operation(operation_name) if op_config is None: return None - # Check if profile is specified profile = None if op_config.profile and op_config.profile in self._config.profiles: profile = self._config.profiles[op_config.profile] + def pick(field: str, default: object) -> object: + value = getattr(op_config, field) + if value is not None: + return value + if profile is not None and getattr(profile, field) is not None: + return getattr(profile, field) + return default + return OperationContext( - retryable=op_config.retryable if op_config.retryable is not None - else (profile.retryable if profile else False), - idempotent=op_config.idempotent if op_config.idempotent is not None - else (profile.idempotent if profile else False), - runbook_url=op_config.runbook_url, - escalation_level=profile.escalation_level if profile else None, - safe_to_restart=profile.safe_to_restart if profile else True, - fallback_description=profile.fallback_description if profile else None, + retryable=pick("retryable", False), + idempotent=pick("idempotent", False), + runbook_url=pick("runbook_url", None), + escalation_level=pick("escalation_level", EscalationLevel.AUTO_RESOLVE), + safe_to_restart=pick("safe_to_restart", True), + fallback_description=pick("fallback_description", None), ) def _set_operation_context(self, span: Span, ctx: OperationContext) -> None: """Set operation context attributes on span.""" - span.set_attribute(attr.OPERATION_RETRYABLE, ctx.retryable) - span.set_attribute(attr.OPERATION_IDEMPOTENT, ctx.idempotent) - span.set_attribute(attr.OPERATION_SAFE_TO_RESTART, ctx.safe_to_restart) + span.set_attribute(attr.DECISION_RETRYABLE, ctx.retryable) + span.set_attribute(attr.DECISION_IDEMPOTENT, ctx.idempotent) + span.set_attribute(attr.DECISION_SAFE_TO_RESTART, ctx.safe_to_restart) + span.set_attribute(attr.DECISION_FALLBACK_AVAILABLE, bool(ctx.fallback_description)) if ctx.runbook_url: - span.set_attribute(attr.OPERATION_RUNBOOK_URL, ctx.runbook_url) + span.set_attribute(attr.DECISION_RUNBOOK_URL, ctx.runbook_url) if ctx.escalation_level: - span.set_attribute(attr.OPERATION_ESCALATION_LEVEL, ctx.escalation_level.value) + span.set_attribute(attr.DECISION_ESCALATION_LEVEL, ctx.escalation_level.value) if ctx.fallback_description: - span.set_attribute(attr.OPERATION_FALLBACK_DESCRIPTION, ctx.fallback_description) + span.set_attribute(attr.DECISION_FALLBACK_DESCRIPTION, ctx.fallback_description) def _set_baseline_attributes(self, span: Span, baseline: OperationBaseline) -> None: """Set baseline attributes on span.""" diff --git a/agenttel-python/src/agenttel/topology/registry.py b/agenttel-python/src/agenttel/topology/registry.py index eb82756..d1e1fa6 100644 --- a/agenttel-python/src/agenttel/topology/registry.py +++ b/agenttel-python/src/agenttel/topology/registry.py @@ -82,5 +82,7 @@ def get_topology_attributes(self) -> dict[str, str]: if self._config.topology.domain: attrs[attr.TOPOLOGY_DOMAIN] = self._config.topology.domain if self._config.topology.on_call_channel: - attrs[attr.TOPOLOGY_ONCALL_CHANNEL] = self._config.topology.on_call_channel + attrs[attr.TOPOLOGY_ON_CALL_CHANNEL] = self._config.topology.on_call_channel + if self._config.topology.repo_url: + attrs[attr.TOPOLOGY_REPO_URL] = self._config.topology.repo_url return attrs diff --git a/agenttel-python/tests/test_config.py b/agenttel-python/tests/test_config.py new file mode 100644 index 0000000..56da8b4 --- /dev/null +++ b/agenttel-python/tests/test_config.py @@ -0,0 +1,138 @@ +"""Tests for the shared AgentTel config format and legacy dialects.""" + +import logging + +import pytest + +from agenttel.config import AgentTelConfig, normalize_operation_name, parse_duration_ms +from agenttel.engine import AgentTelEngine + + +class TestCanonicalFormat: + def test_spring_style_document(self): + config = AgentTelConfig.from_dict( + { + "agenttel": { + "topology": {"team": "payments", "tier": "critical", "on-call-channel": "#oncall"}, + "operations": { + "[POST /api/payments]": { + "expected-latency-p50": "45ms", + "expected-latency-p99": "1.5s", + "expected-error-rate": 0.001, + "retryable": True, + "runbook-url": "https://runbooks/payments", + } + }, + "anomaly-detection": {"z-score-threshold": 2.5}, + } + } + ) + assert config.topology.on_call_channel == "#oncall" + op = config.get_operation("POST /api/payments") + assert op is not None + assert op.latency_p50_ms == 45.0 + assert op.latency_p99_ms == 1500.0 + assert op.retryable is True + assert config.anomaly_detection.z_score_threshold == 2.5 + + def test_bare_and_bracketed_keys_are_equivalent(self): + bare = AgentTelConfig.from_dict({"agenttel": {"operations": {"GET /x": {}}}}) + bracketed = AgentTelConfig.from_dict({"agenttel": {"operations": {"[GET /x]": {}}}}) + assert list(bare.operations) == list(bracketed.operations) == ["GET /x"] + + def test_numeric_latency_is_milliseconds(self): + config = AgentTelConfig.from_dict( + {"agenttel": {"operations": {"GET /x": {"expected-latency-p50": 45}}}} + ) + assert config.get_operation("GET /x").latency_p50_ms == 45.0 + + def test_no_deprecation_warning_for_canonical_form(self, caplog): + with caplog.at_level(logging.WARNING, logger="agenttel.config"): + AgentTelConfig.from_dict({"agenttel": {"topology": {"on-call-channel": "#x"}}}) + assert caplog.records == [] + + +class TestLegacyDialects: + def test_flat_camel_case_go_dialect(self, caplog): + with caplog.at_level(logging.WARNING, logger="agenttel.config"): + config = AgentTelConfig.from_dict( + { + "topology": {"team": "t", "onCallChannel": "#c"}, + "operations": {"POST /p": {"expectedLatencyP50": "45ms", "runbookUrl": "u"}}, + "anomalyDetection": {"zScoreThreshold": 4}, + } + ) + assert config.topology.on_call_channel == "#c" + assert config.get_operation("POST /p").latency_p50_ms == 45.0 + assert config.get_operation("POST /p").runbook_url == "u" + assert config.anomaly_detection.z_score_threshold == 4 + assert any("deprecated" in r.message for r in caplog.records) + + def test_node_operations_list_dialect(self): + config = AgentTelConfig.from_dict( + { + "topology": {"on_call_channel": "#c"}, + "operations": [ + {"name": "POST /p", "baseline_latency_p50_ms": 45, "baseline_error_rate": 0.01} + ], + "rolling_window_size": 50, + "anomaly_zscore_threshold": 2.0, + } + ) + op = config.get_operation("POST /p") + assert op.latency_p50_ms == 45.0 + assert op.expected_error_rate == 0.01 + assert config.baselines.rolling_window_size == 50 + assert config.anomaly_detection.z_score_threshold == 2.0 + + +class TestOperationNames: + @pytest.mark.parametrize( + "raw,expected", + [ + ("[POST /api/payments]", "POST /api/payments"), + ("post /api/payments", "POST /api/payments"), + ("GET /orders/:id", "GET /orders/{id}"), + ("GET /orders/{id}", "GET /orders/{id}"), + ("process-payment", "process-payment"), + ], + ) + def test_normalize(self, raw, expected): + assert normalize_operation_name(raw) == expected + + @pytest.mark.parametrize( + "raw,expected", [("45ms", 45.0), ("2s", 2000.0), ("1m", 60000.0), (45, 45.0), ("12", 12.0)] + ) + def test_parse_duration(self, raw, expected): + assert parse_duration_ms(raw) == expected + + def test_invalid_duration(self): + with pytest.raises(ValueError): + parse_duration_ms("fast") + + +class TestDecisionResolution: + def test_profile_defaults_and_overrides(self): + engine = AgentTelEngine( + AgentTelConfig.from_dict( + { + "agenttel": { + "profiles": { + "critical-write": { + "retryable": False, + "runbook-url": "https://profile", + "escalation-level": "page_oncall", + } + }, + "operations": { + "[POST /p]": {"profile": "critical-write", "retryable": True}, + }, + } + } + ) + ) + ctx = engine.processor.resolve_operation_context("POST /p") + assert ctx.retryable is True + assert ctx.runbook_url == "https://profile" + assert ctx.escalation_level.value == "page_oncall" + assert engine.processor.resolve_operation_context("GET /unknown") is None diff --git a/agenttel-python/tests/test_fastapi_integration.py b/agenttel-python/tests/test_fastapi_integration.py new file mode 100644 index 0000000..c662f51 --- /dev/null +++ b/agenttel-python/tests/test_fastapi_integration.py @@ -0,0 +1,112 @@ +"""End-to-end: real uvicorn server + FastAPI + OTel instrumentation + AgentTel. + +Mirrors the Spring Boot EnrichmentTest: one request, then assert the HTTP server +span carries topology, static baselines and decision attributes. +""" + +import socket +import threading +import time + +import httpx +import pytest +from opentelemetry import trace +from opentelemetry.trace import SpanKind + +fastapi = pytest.importorskip("fastapi") +uvicorn = pytest.importorskip("uvicorn") +pytest.importorskip("opentelemetry.instrumentation.fastapi") + +from agenttel.fastapi import instrument_fastapi # noqa: E402 + +CONFIG = """ +agenttel: + topology: + team: payments-platform + tier: critical + domain: commerce + on-call-channel: "#payments-oncall" + operations: + "[POST /api/payments]": + expected-latency-p50: 45ms + expected-latency-p99: 200ms + expected-error-rate: 0.001 + retryable: true + idempotent: true + runbook-url: https://wiki.example.com/runbooks/payments + escalation-level: page_oncall + "GET /api/payments/{payment_id}": + expected-latency-p50: 15 + expected-latency-p99: 80 +""" + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture() +def server_spans(tmp_path, span_exporter): + config_path = tmp_path / "agenttel.yml" + config_path.write_text(CONFIG) + + app = fastapi.FastAPI() + + @app.post("/api/payments") + async def create_payment(): + return {"ok": True} + + @app.get("/api/payments/{payment_id}") + async def get_payment(payment_id: str): + return {"id": payment_id} + + provider_before = trace.get_tracer_provider() + instrument_fastapi(app, config_path=str(config_path)) + # The app's (here: the test suite's) SDK provider is reused, not replaced. + assert trace.get_tracer_provider() is provider_before + + port = _free_port() + server = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="off") + ) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.time() + 10 + while not server.started and time.time() < deadline: + time.sleep(0.02) + assert server.started, "uvicorn did not start" + + def request(method: str, path: str) -> list: + httpx.request(method, f"http://127.0.0.1:{port}{path}", timeout=5).raise_for_status() + return [s for s in span_exporter.get_finished_spans() if s.kind == SpanKind.SERVER] + + yield request + + server.should_exit = True + thread.join(timeout=10) + + +def test_server_span_has_topology_baseline_and_decisions(server_spans): + span = server_spans("POST", "/api/payments")[-1] + attrs = span.attributes + + assert span.name == "POST /api/payments" + assert attrs["agenttel.topology.team"] == "payments-platform" + assert attrs["agenttel.topology.on_call_channel"] == "#payments-oncall" + assert span.resource.attributes["agenttel.topology.team"] == "payments-platform" + assert attrs["agenttel.baseline.latency_p50_ms"] == 45.0 + assert attrs["agenttel.baseline.latency_p99_ms"] == 200.0 + assert attrs["agenttel.baseline.error_rate"] == 0.001 + assert attrs["agenttel.decision.retryable"] is True + assert attrs["agenttel.decision.idempotent"] is True + assert attrs["agenttel.decision.runbook_url"] == "https://wiki.example.com/runbooks/payments" + assert attrs["agenttel.decision.escalation_level"] == "page_oncall" + + +def test_parameterized_route_uses_route_template(server_spans): + span = server_spans("GET", "/api/payments/pay_123")[-1] + assert span.attributes["http.route"] == "/api/payments/{payment_id}" + assert span.attributes["agenttel.baseline.latency_p50_ms"] == 15.0 + assert span.attributes["agenttel.decision.retryable"] is False diff --git a/examples/fastapi-example/agenttel.yml b/examples/fastapi-example/agenttel.yml index cb7bcc4..132bab7 100644 --- a/examples/fastapi-example/agenttel.yml +++ b/examples/fastapi-example/agenttel.yml @@ -8,12 +8,12 @@ agenttel: dependencies: - name: payment-gateway - type: rest_api - criticality: critical + type: external_api + criticality: required timeout-ms: 5000 - name: customer-db type: database - criticality: critical + criticality: required timeout-ms: 3000 consumers: @@ -29,7 +29,7 @@ agenttel: expected-error-rate: 0.001 retryable: false idempotent: false - "GET /api/payments/{id}": + "GET /api/payments/{payment_id}": expected-latency-p50: 15ms expected-latency-p99: 80ms retryable: true @@ -47,5 +47,5 @@ agenttel: target: 0.999 type: availability payment-latency: - target: 200 - type: latency + target: 0.99 + type: latency_p99 diff --git a/examples/fastapi-example/app.py b/examples/fastapi-example/app.py index d36c21c..77dd3e5 100644 --- a/examples/fastapi-example/app.py +++ b/examples/fastapi-example/app.py @@ -13,7 +13,7 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanExportProcessor +from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.semconv.resource import ResourceAttributes from agenttel import AgentTelEngine @@ -31,7 +31,7 @@ exporter = OTLPSpanExporter(endpoint=otel_endpoint, insecure=True) provider = TracerProvider(resource=resource) -provider.add_span_processor(BatchSpanExportProcessor(exporter)) +provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer("fastapi-payment-service") @@ -76,7 +76,7 @@ async def create_payment() -> dict: @app.get("/api/payments/{payment_id}") async def get_payment(payment_id: str) -> dict: """Retrieve a payment by ID.""" - with tracer.start_as_current_span("GET /api/payments/{id}"): + with tracer.start_as_current_span("GET /api/payments/{payment_id}"): # Simulate read latency (10-25ms) await asyncio.sleep((10 + random.random() * 15) / 1000) From 33502aff8e89042821944756fd3249059a78300d Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Fri, 16 Oct 2026 16:32:49 +0100 Subject: [PATCH 2/7] Node SDK: load under plain Node (ESM + CJS), shared config, decisions - Build ESM with NodeNext so relative imports carry .js extensions (fixes ERR_MODULE_NOT_FOUND for ./auth); mark dist/cjs as CommonJS; types-first exports map; engines node >= 18 - Load the shared agenttel.yml format; the pre-0.4.0 flat/snake_case dialect and operations list still load with a warning - Express/Fastify middleware set decision attributes; accept { engine }; Express re-checks the matched route before the response ends, Fastify records http.route - Add AgentTelEngine.fromConfigFile, engine.operations, engine.resourceAttributes(), parseConfig, normalizeOperationName, parseDurationMs, enrichOperation - Integration test: real Express and Fastify servers with OTel instrumentation against the built package; test:pack installs the packed tarball in a fresh project (ESM import, CJS require, servers) - Examples register engine.spanProcessor and HTTP instrumentation Signed-off-by: Rohit Kumar --- agenttel-node/package-lock.json | 1668 +++++++++++++++++- agenttel-node/package.json | 39 +- agenttel-node/scripts/mark-cjs.mjs | 5 + agenttel-node/scripts/pack-smoke.mjs | 52 + agenttel-node/src/agent/mcp/server.ts | 6 +- agenttel-node/src/baseline/provider.ts | 7 +- agenttel-node/src/config.ts | 253 ++- agenttel-node/src/engine.ts | 88 +- agenttel-node/src/enrich.ts | 51 + agenttel-node/src/index.ts | 6 +- agenttel-node/src/middleware/express.ts | 89 +- agenttel-node/src/middleware/fastify.ts | 59 +- agenttel-node/src/operations.ts | 83 + agenttel-node/src/processor.ts | 37 +- agenttel-node/tests/config.test.ts | 80 +- agenttel-node/tests/integration.test.ts | 54 + agenttel-node/tests/integration/agenttel.yml | 23 + agenttel-node/tests/integration/server.cjs | 72 + agenttel-node/tsconfig.cjs.json | 4 +- agenttel-node/tsconfig.json | 4 +- examples/express-example/package.json | 7 +- examples/express-example/src/index.ts | 42 +- examples/express-example/src/telemetry.ts | 27 + examples/fastify-example/package.json | 6 +- examples/fastify-example/src/index.ts | 42 +- examples/fastify-example/src/telemetry.ts | 27 + 26 files changed, 2593 insertions(+), 238 deletions(-) create mode 100644 agenttel-node/scripts/mark-cjs.mjs create mode 100644 agenttel-node/scripts/pack-smoke.mjs create mode 100644 agenttel-node/src/enrich.ts create mode 100644 agenttel-node/src/operations.ts create mode 100644 agenttel-node/tests/integration.test.ts create mode 100644 agenttel-node/tests/integration/agenttel.yml create mode 100644 agenttel-node/tests/integration/server.cjs create mode 100644 examples/express-example/src/telemetry.ts create mode 100644 examples/fastify-example/src/telemetry.ts diff --git a/agenttel-node/package-lock.json b/agenttel-node/package-lock.json index f357a35..2bd8a0f 100644 --- a/agenttel-node/package-lock.json +++ b/agenttel-node/package-lock.json @@ -1,31 +1,49 @@ { "name": "@agenttel/node", - "version": "0.1.0", + "version": "0.4.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agenttel/node", - "version": "0.1.0", + "version": "0.4.0-alpha.0", "license": "Apache-2.0", "dependencies": { "js-yaml": "^4.1.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/sdk-trace-node": "^1.25.0", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-express": "^0.47.1", + "@opentelemetry/instrumentation-http": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", "@types/js-yaml": "^4.0.9", "@types/node": "^20.14.0", + "express": "^4.21.2", + "fastify": "^4.29.1", "typescript": "^5.5.0", "vitest": "^2.0.0" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { + "@anthropic-ai/sdk": "^0.30.0", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/sdk-trace-node": "^1.25.0" + "@opentelemetry/sdk-trace-node": "^1.25.0", + "openai": "^4.0.0" }, "peerDependenciesMeta": { + "@anthropic-ai/sdk": { + "optional": true + }, "@opentelemetry/sdk-trace-node": { "optional": true + }, + "openai": { + "optional": true } } }, @@ -420,6 +438,45 @@ "node": ">=12" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -437,6 +494,19 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@opentelemetry/context-async-hooks": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", @@ -466,6 +536,65 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz", + "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz", + "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/semantic-conventions": "1.28.0", + "forwarded-parse": "2.1.2", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@opentelemetry/propagator-b3": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", @@ -564,6 +693,13 @@ "node": ">=14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -938,6 +1074,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1051,12 +1194,115 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1067,6 +1313,79 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1077,6 +1396,37 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1104,6 +1454,53 @@ "node": ">= 16" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1132,6 +1529,79 @@ "node": ">=6" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1139,6 +1609,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1178,6 +1661,13 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1188,6 +1678,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1198,6 +1698,265 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.7.tgz", + "integrity": "sha512-b8mggfg2R+m2OAb3Z+2HcJxUgU+2cY1JQondBDyXLdrEKNPxd96MvnB5yWKZdupsuX6GdBaAZHZo9msHX/Bb7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1213,6 +1972,170 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1225,6 +2148,35 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1242,6 +2194,89 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1268,6 +2303,76 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1290,7 +2395,64 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/postcss": { "version": "8.5.8", @@ -1321,6 +2483,165 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1366,6 +2687,61 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1379,6 +2755,171 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1386,6 +2927,16 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1396,6 +2947,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1403,6 +2964,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -1410,6 +2981,29 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1454,6 +3048,40 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1475,6 +3103,36 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/agenttel-node/package.json b/agenttel-node/package.json index 4b21a83..8e317bc 100644 --- a/agenttel-node/package.json +++ b/agenttel-node/package.json @@ -1,35 +1,42 @@ { "name": "@agenttel/node", - "version": "0.3.0-alpha.0", - "description": "AgentTel SDK for server-side Node.js — agent-ready observability enrichment", + "version": "0.4.0-alpha.0", + "description": "AgentTel SDK for server-side Node.js \u2014 agent-ready observability enrichment", "type": "module", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "exports": { ".": { + "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js", - "types": "./dist/esm/index.d.ts" - } + "require": "./dist/cjs/index.js" + }, + "./package.json": "./package.json" }, - "files": ["dist"], + "files": [ + "dist", + "README.md" + ], "scripts": { - "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json", + "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/ tests/", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test:pack": "npm run build && node scripts/pack-smoke.mjs", + "prepack": "npm run build", + "pretest": "npm run build" }, "dependencies": { "js-yaml": "^4.1.0" }, "peerDependencies": { + "@anthropic-ai/sdk": "^0.30.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-trace-node": "^1.25.0", - "openai": "^4.0.0", - "@anthropic-ai/sdk": "^0.30.0" + "openai": "^4.0.0" }, "peerDependenciesMeta": { "@opentelemetry/sdk-trace-node": { @@ -44,9 +51,16 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/sdk-trace-node": "^1.25.0", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-express": "^0.47.1", + "@opentelemetry/instrumentation-http": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", "@types/js-yaml": "^4.0.9", "@types/node": "^20.14.0", + "express": "^4.21.2", + "fastify": "^4.29.1", "typescript": "^5.5.0", "vitest": "^2.0.0" }, @@ -55,5 +69,8 @@ "type": "git", "url": "https://github.com/AgentTel/agenttel-sdk.git", "directory": "agenttel-node" + }, + "engines": { + "node": ">=18" } } diff --git a/agenttel-node/scripts/mark-cjs.mjs b/agenttel-node/scripts/mark-cjs.mjs new file mode 100644 index 0000000..44d48ec --- /dev/null +++ b/agenttel-node/scripts/mark-cjs.mjs @@ -0,0 +1,5 @@ +// The package root is "type": "module", so Node would parse dist/cjs/*.js as ESM. +// A nested package.json marks the CommonJS build as CommonJS. +import { writeFileSync } from 'node:fs'; + +writeFileSync(new URL('../dist/cjs/package.json', import.meta.url), '{ "type": "commonjs" }\n'); diff --git a/agenttel-node/scripts/pack-smoke.mjs b/agenttel-node/scripts/pack-smoke.mjs new file mode 100644 index 0000000..ce428c0 --- /dev/null +++ b/agenttel-node/scripts/pack-smoke.mjs @@ -0,0 +1,52 @@ +// Installs the packed tarball into a fresh project and checks it under plain Node: +// 1. ESM: import('@agenttel/node') +// 2. CJS: require('@agenttel/node') +// 3. A real Express and Fastify server with OTel instrumentation gets enriched server spans. +// +// Usage: node scripts/pack-smoke.mjs [path/to/agenttel-node-x.y.z.tgz] +// Without an argument the package is packed first (run `npm run build` before). +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const work = mkdtempSync(path.join(tmpdir(), 'agenttel-node-smoke-')); +const sh = (cmd, args, opts = {}) => + execFileSync(cmd, args, { cwd: work, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'inherit'], ...opts }); + +let tarball = process.argv[2] && path.resolve(process.argv[2]); +if (!tarball) { + const name = sh('npm', ['pack', '--silent', '--pack-destination', work], { cwd: pkgRoot }).trim().split('\n').pop(); + tarball = path.join(work, name); +} +console.log(`smoke: ${path.basename(tarball)} on node ${process.version} in ${work}`); + +const devDeps = JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8')).devDependencies; +const pin = (name) => `${name}@${devDeps[name]}`; +writeFileSync(path.join(work, 'package.json'), JSON.stringify({ name: 'smoke', private: true })); +sh('npm', [ + 'install', '--no-audit', '--no-fund', '--loglevel=error', tarball, + pin('@opentelemetry/api'), pin('@opentelemetry/sdk-trace-node'), pin('@opentelemetry/sdk-trace-base'), + pin('@opentelemetry/resources'), pin('@opentelemetry/instrumentation'), + pin('@opentelemetry/instrumentation-http'), pin('@opentelemetry/instrumentation-express'), + pin('express'), pin('fastify'), +]); + +const check = "if (typeof m.AgentTelEngine !== 'function' || typeof m.expressMiddleware !== 'function') throw new Error('missing exports')"; +sh(process.execPath, ['--input-type=module', '-e', `const m = await import('@agenttel/node'); ${check}; console.log('ESM import ok')`], { stdio: 'inherit' }); +sh(process.execPath, ['-e', `const m = require('@agenttel/node'); ${check}; console.log('CJS require ok')`], { stdio: 'inherit' }); + +copyFileSync(path.join(pkgRoot, 'tests/integration/server.cjs'), path.join(work, 'server.cjs')); +copyFileSync(path.join(pkgRoot, 'tests/integration/agenttel.yml'), path.join(work, 'agenttel.yml')); +for (const framework of ['express', 'fastify']) { + const spans = JSON.parse(sh(process.execPath, ['server.cjs', framework, 'agenttel.yml'])); + const span = spans.find((s) => s.route === '/api/payments'); + const a = span?.attributes ?? {}; + if (a['agenttel.baseline.latency_p50_ms'] !== 45 || a['agenttel.decision.retryable'] !== true || a['agenttel.topology.team'] !== 'payments-platform') { + throw new Error(`${framework}: server span not enriched: ${JSON.stringify(spans)}`); + } + console.log(`${framework}: server span enriched (p50=45, retryable=true, team=payments-platform)`); +} +console.log('smoke: OK'); diff --git a/agenttel-node/src/agent/mcp/server.ts b/agenttel-node/src/agent/mcp/server.ts index 839928f..37b594c 100644 --- a/agenttel-node/src/agent/mcp/server.ts +++ b/agenttel-node/src/agent/mcp/server.ts @@ -1,7 +1,7 @@ -import { type AgentIdentity, type AuthConfig, ToolPermissionRegistry, resolveKey } from './auth'; +import { type AgentIdentity, type AuthConfig, ToolPermissionRegistry, resolveKey } from './auth.js'; -export type { AgentIdentity, AuthConfig } from './auth'; -export { ToolPermission, ToolPermissionRegistry } from './auth'; +export type { AgentIdentity, AuthConfig } from './auth.js'; +export { ToolPermission, ToolPermissionRegistry } from './auth.js'; export interface MCPTool { name: string; diff --git a/agenttel-node/src/baseline/provider.ts b/agenttel-node/src/baseline/provider.ts index 5f85271..95d17d7 100644 --- a/agenttel-node/src/baseline/provider.ts +++ b/agenttel-node/src/baseline/provider.ts @@ -1,4 +1,5 @@ import type { OperationBaseline } from '../interfaces.js'; +import { normalizeOperationName } from '../operations.js'; /** Returns baseline data for a named operation. */ export interface BaselineProvider { @@ -10,10 +11,12 @@ export class StaticBaselineProvider implements BaselineProvider { private readonly baselines: Map; constructor(baselines: Record) { - this.baselines = new Map(Object.entries(baselines)); + this.baselines = new Map( + Object.entries(baselines).map(([name, b]) => [normalizeOperationName(name), b] as const), + ); } getBaseline(operationName: string): OperationBaseline | undefined { - return this.baselines.get(operationName); + return this.baselines.get(normalizeOperationName(operationName)); } } diff --git a/agenttel-node/src/config.ts b/agenttel-node/src/config.ts index 7e300ca..9900487 100644 --- a/agenttel-node/src/config.ts +++ b/agenttel-node/src/config.ts @@ -1,7 +1,25 @@ import { readFileSync } from 'node:fs'; import yaml from 'js-yaml'; +import { normalizeOperationName } from './operations.js'; + +/* + * The canonical format is shared by every AgentTel SDK (JVM, Go, Node.js, Python): + * + * agenttel: + * topology: + * team: payments-platform + * on-call-channel: "#payments-oncall" + * operations: + * "[POST /api/payments]": # brackets optional + * expected-latency-p50: 45ms # duration string, or a number in ms + * retryable: true + * + * The pre-0.4.0 Node.js dialect (no `agenttel:` root, snake_case keys, `operations` + * as a list of `{ name, baseline_latency_p50_ms }`) still loads, with a warning. + */ export interface TopologyConfig { + serviceName?: string; team?: string; tier?: string; domain?: string; @@ -26,10 +44,30 @@ export interface ConsumerConfig { slaLatencyMs?: number; } -export interface OperationConfig { - name: string; +/** Shared decision defaults that operations can reference by name. */ +export interface ProfileConfig { + retryable?: boolean; + idempotent?: boolean; + runbookUrl?: string; + fallbackDescription?: string; + escalationLevel?: string; + safeToRestart?: boolean; +} + +/** Per-operation configuration. Explicit values override the referenced profile. */ +export interface OperationConfig extends ProfileConfig { + profile?: string; + /** Duration string ("45ms", "1s") or number of milliseconds. */ + expectedLatencyP50?: string | number; + expectedLatencyP99?: string | number; + expectedErrorRate?: number; + /** @deprecated pre-0.4.0 list form; use the `operations` map key instead. */ + name?: string; + /** @deprecated use expectedLatencyP50 */ baselineLatencyP50Ms?: number; + /** @deprecated use expectedLatencyP99 */ baselineLatencyP99Ms?: number; + /** @deprecated use expectedErrorRate */ baselineErrorRate?: number; } @@ -42,20 +80,24 @@ export interface SLOConfig { } export interface AgentTelConfig { + enabled?: boolean; topology?: TopologyConfig; dependencies?: DependencyConfig[]; consumers?: ConsumerConfig[]; - operations?: OperationConfig[]; + profiles?: Record; + /** Map keyed by "METHOD /route". The array form is deprecated but still accepted. */ + operations?: Record | OperationConfig[]; slos?: SLOConfig[]; rollingWindowSize?: number; rollingMinSamples?: number; + anomalyDetectionEnabled?: boolean; anomalyZScoreThreshold?: number; } -/** Load config from a YAML file. */ +/** Load config from a YAML file (canonical `agenttel:` format or a legacy dialect). */ export function loadConfig(path: string): AgentTelConfig { const content = readFileSync(path, 'utf-8'); - const raw = yaml.load(content) as Record; + const raw = (yaml.load(content) ?? {}) as Record; return parseConfig(raw); } @@ -91,63 +133,182 @@ export function defaultConfig(): AgentTelConfig { }; } -function parseConfig(raw: Record): AgentTelConfig { +type Raw = Record; + +// Sections whose keys are user-chosen names, not schema keys. +const NAMED_MAPS = new Set(['operations', 'profiles', 'slo', 'agent-roles']); +const ENTRY = Symbol('entry'); + +function toKebab(key: string): string { + return key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/_/g, '-').toLowerCase(); +} + +function kebabKeys(value: unknown, section: string | symbol | undefined, legacy: Set): unknown { + if (Array.isArray(value)) return value.map((v) => kebabKeys(v, undefined, legacy)); + if (!value || typeof value !== 'object') return value; + const out: Raw = {}; + for (const [key, v] of Object.entries(value as Raw)) { + if (typeof section === 'string' && NAMED_MAPS.has(section)) { + out[section === 'operations' ? normalizeOperationName(key) : key] = kebabKeys(v, ENTRY, legacy); + continue; + } + const k = toKebab(key); + if (k !== key) legacy.add(key); + out[k] = kebabKeys(v, k, legacy); + } + return out; +} + +const str = (v: unknown): string | undefined => (v === undefined || v === null ? undefined : String(v)); +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v)) ? Number(v) : undefined); +const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : v === 'true' ? true : v === 'false' ? false : undefined); +const obj = (v: unknown): Raw => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Raw) : {}); + +function warn(message: string): void { + // eslint-disable-next-line no-console + console.warn(`[agenttel] ${message}`); +} + +function parseProfile(p: Raw): ProfileConfig { + return { + retryable: bool(p['retryable']), + idempotent: bool(p['idempotent']), + runbookUrl: str(p['runbook-url']), + fallbackDescription: str(p['fallback-description']), + escalationLevel: str(p['escalation-level']), + safeToRestart: bool(p['safe-to-restart']), + }; +} + +function parseOperation(o: Raw): OperationConfig { + const latency = (v: unknown) => (typeof v === 'number' ? v : str(v)); + return { + ...parseProfile(o), + profile: str(o['profile']), + expectedLatencyP50: latency(o['expected-latency-p50'] ?? o['baseline-latency-p50-ms']), + expectedLatencyP99: latency(o['expected-latency-p99'] ?? o['baseline-latency-p99-ms']), + expectedErrorRate: num(o['expected-error-rate'] ?? o['baseline-error-rate']), + }; +} + +/** Parse an already-loaded YAML/JSON document into an AgentTelConfig. */ +export function parseConfig(input: Record): AgentTelConfig { + let root: Raw = input ?? {}; + if (root['agenttel'] && typeof root['agenttel'] === 'object') { + root = root['agenttel'] as Raw; + } else if (Object.keys(root).length > 0) { + warn("config without a top-level 'agenttel:' key is deprecated; nest the configuration under 'agenttel:'"); + } + + const legacy = new Set(); + const raw = kebabKeys(root, undefined, legacy) as Raw; const config: AgentTelConfig = defaultConfig(); - if (raw.topology && typeof raw.topology === 'object') { - const t = raw.topology as Record; + if (raw['enabled'] !== undefined) config.enabled = bool(raw['enabled']); + + const t = obj(raw['topology']); + if (raw['topology']) { config.topology = { - team: t.team as string | undefined, - tier: t.tier as string | undefined, - domain: t.domain as string | undefined, - onCallChannel: (t.on_call_channel ?? t.onCallChannel) as string | undefined, - repoUrl: (t.repo_url ?? t.repoUrl) as string | undefined, + serviceName: str(t['service-name']), + team: str(t['team']), + tier: str(t['tier']), + domain: str(t['domain']), + onCallChannel: str(t['on-call-channel']), + repoUrl: str(t['repo-url']), }; } - if (Array.isArray(raw.dependencies)) { - config.dependencies = raw.dependencies.map((d: Record) => ({ - name: d.name as string, - type: d.type as string | undefined, - criticality: d.criticality as string | undefined, - protocol: d.protocol as string | undefined, - timeoutMs: (d.timeout_ms ?? d.timeoutMs) as number | undefined, - circuitBreaker: (d.circuit_breaker ?? d.circuitBreaker) as boolean | undefined, - fallback: d.fallback as string | undefined, - healthEndpoint: (d.health_endpoint ?? d.healthEndpoint) as string | undefined, + if (Array.isArray(raw['dependencies'])) { + config.dependencies = (raw['dependencies'] as Raw[]).map((d) => ({ + name: String(d['name']), + type: str(d['type']), + criticality: str(d['criticality']), + protocol: str(d['protocol']), + timeoutMs: num(d['timeout-ms']), + circuitBreaker: bool(d['circuit-breaker']), + fallback: str(d['fallback']), + healthEndpoint: str(d['health-endpoint']), })); } - if (Array.isArray(raw.consumers)) { - config.consumers = raw.consumers.map((c: Record) => ({ - name: c.name as string, - pattern: c.pattern as string | undefined, - slaLatencyMs: (c.sla_latency_ms ?? c.slaLatencyMs) as number | undefined, + if (Array.isArray(raw['consumers'])) { + config.consumers = (raw['consumers'] as Raw[]).map((c) => ({ + name: String(c['name']), + pattern: str(c['pattern']), + slaLatencyMs: num(c['sla-latency-ms']), })); } - if (Array.isArray(raw.operations)) { - config.operations = raw.operations.map((o: Record) => ({ - name: o.name as string, - baselineLatencyP50Ms: (o.baseline_latency_p50_ms ?? o.baselineLatencyP50Ms) as number | undefined, - baselineLatencyP99Ms: (o.baseline_latency_p99_ms ?? o.baselineLatencyP99Ms) as number | undefined, - baselineErrorRate: (o.baseline_error_rate ?? o.baselineErrorRate) as number | undefined, - })); + if (raw['profiles']) { + config.profiles = {}; + for (const [name, p] of Object.entries(obj(raw['profiles']))) config.profiles[name] = parseProfile(obj(p)); } - if (Array.isArray(raw.slos)) { - config.slos = raw.slos.map((s: Record) => ({ - name: s.name as string, - operationName: (s.operation_name ?? s.operationName) as string | undefined, - type: s.type as string | undefined, - target: s.target as number, - windowSeconds: (s.window_seconds ?? s.windowSeconds) as number | undefined, - })); + let ops = raw['operations']; + if (Array.isArray(ops)) { + warn("'operations' as a list is deprecated; use a map keyed by \"METHOD /route\""); + const converted: Raw = {}; + for (const item of ops as Raw[]) { + const { name, ...rest } = obj(item); + if (name) converted[normalizeOperationName(String(name))] = rest; + } + ops = converted; + } + if (ops) { + const operations: Record = {}; + for (const [name, o] of Object.entries(obj(ops))) { + const op = obj(o); + for (const k of ['baseline-latency-p50-ms', 'baseline-latency-p99-ms', 'baseline-error-rate']) { + if (k in op) legacy.add(k); + } + operations[name] = parseOperation(op); + } + config.operations = operations; + } + + const slos: SLOConfig[] = []; + for (const [name, s] of Object.entries(obj(raw['slo']))) { + const so = obj(s); + slos.push({ + name: str(so['name']) ?? name, + operationName: str(so['operation'] ?? so['operation-name']), + type: str(so['type']), + target: num(so['target']) ?? 0.999, + windowSeconds: num(so['window-seconds']), + }); } + if (Array.isArray(raw['slos'])) { + for (const so of raw['slos'] as Raw[]) { + slos.push({ + name: String(so['name']), + operationName: str(so['operation-name']), + type: str(so['type']), + target: num(so['target']) ?? 0.999, + windowSeconds: num(so['window-seconds']), + }); + } + } + if (slos.length > 0) config.slos = slos; + + const baselines = obj(raw['baselines']); + const windowSize = num(baselines['rolling-window-size'] ?? raw['rolling-window-size']); + if (windowSize !== undefined) config.rollingWindowSize = windowSize; + const minSamples = num(baselines['rolling-min-samples'] ?? raw['rolling-min-samples']); + if (minSamples !== undefined) config.rollingMinSamples = minSamples; - if (typeof raw.rolling_window_size === 'number') config.rollingWindowSize = raw.rolling_window_size; - if (typeof raw.rolling_min_samples === 'number') config.rollingMinSamples = raw.rolling_min_samples; - if (typeof raw.anomaly_zscore_threshold === 'number') config.anomalyZScoreThreshold = raw.anomaly_zscore_threshold; + const anomaly = obj(raw['anomaly-detection']); + if (anomaly['enabled'] !== undefined) config.anomalyDetectionEnabled = bool(anomaly['enabled']); + const threshold = num(anomaly['z-score-threshold'] ?? raw['anomaly-zscore-threshold']); + if (threshold !== undefined) config.anomalyZScoreThreshold = threshold; + for (const k of ['rolling-window-size', 'rolling-min-samples', 'anomaly-zscore-threshold', 'slos']) { + if (k in raw) legacy.add(k); + } + if (legacy.size > 0) { + warn( + `config uses deprecated key spellings [${[...legacy].sort().join(', ')}]; use the shared kebab-case ` + + "format (e.g. 'on-call-channel', 'expected-latency-p50', 'baselines.rolling-window-size')", + ); + } return config; } diff --git a/agenttel-node/src/engine.ts b/agenttel-node/src/engine.ts index 2b949a5..ca2fe4c 100644 --- a/agenttel-node/src/engine.ts +++ b/agenttel-node/src/engine.ts @@ -1,5 +1,7 @@ -import type { AgentTelConfig } from './config.js'; -import { defaultConfig } from './config.js'; +import type { AgentTelConfig, OperationConfig } from './config.js'; +import { defaultConfig, loadConfig } from './config.js'; +import { OperationRegistry, normalizeOperationName, parseDurationMs } from './operations.js'; +import * as attrs from './attributes.js'; import { AnomalyDetector } from './anomaly/detector.js'; import { StaticBaselineProvider, type BaselineProvider } from './baseline/provider.js'; import { RollingBaselineProvider } from './baseline/rolling.js'; @@ -21,8 +23,12 @@ export class AgentTelEngine { readonly errorClassifier: ErrorClassifier; readonly eventEmitter: AgentTelEventEmitter; readonly spanProcessor: AgentTelSpanProcessor; + /** Per-operation decision context (retryable, runbook, escalation, ...). */ + readonly operations: OperationRegistry; + readonly config: AgentTelConfig; private constructor(config: AgentTelConfig) { + this.config = config; // Topology this.topology = new TopologyRegistry(); if (config.topology) { @@ -58,18 +64,22 @@ export class AgentTelEngine { config.rollingMinSamples ?? 10, ); + const operations = normalizeOperations(config.operations); + this.operations = new OperationRegistry(operations, config.profiles ?? {}); const staticBaselines: Record = {}; - if (config.operations) { - for (const op of config.operations) { - staticBaselines[op.name] = { - operationName: op.name, - latencyP50Ms: op.baselineLatencyP50Ms ?? 0, - latencyP99Ms: op.baselineLatencyP99Ms ?? 0, - errorRate: op.baselineErrorRate ?? 0, - source: 'static', - updatedAt: new Date(), - }; - } + for (const [name, op] of Object.entries(operations)) { + const p50 = parseDurationMs(op.expectedLatencyP50 ?? op.baselineLatencyP50Ms); + const p99 = parseDurationMs(op.expectedLatencyP99 ?? op.baselineLatencyP99Ms); + const errorRate = op.expectedErrorRate ?? op.baselineErrorRate; + if (p50 === undefined && p99 === undefined && errorRate === undefined) continue; + staticBaselines[name] = { + operationName: name, + latencyP50Ms: p50 ?? 0, + latencyP99Ms: p99 ?? 0, + errorRate: errorRate ?? 0, + source: 'static', + updatedAt: new Date(), + }; } const staticProvider = new StaticBaselineProvider(staticBaselines); this.baselineProvider = new CompositeBaselineProvider(staticProvider, this.rollingProvider); @@ -106,9 +116,36 @@ export class AgentTelEngine { sloTracker: this.sloTracker, errorClassifier: this.errorClassifier, eventEmitter: this.eventEmitter, + operations: this.operations, }); } + /** Alias of `topology`. */ + get topologyRegistry(): TopologyRegistry { + return this.topology; + } + + /** + * Topology as OTel resource attributes, for `new Resource(engine.resourceAttributes())` + * (or `resourceFromAttributes` in OTel JS 2.x). + */ + resourceAttributes(): Record { + const out: Record = {}; + const t = this.topology; + if (this.config.topology?.serviceName) out['service.name'] = this.config.topology.serviceName; + if (t.team) out[attrs.TOPOLOGY_TEAM] = t.team; + if (t.tier) out[attrs.TOPOLOGY_TIER] = t.tier; + if (t.domain) out[attrs.TOPOLOGY_DOMAIN] = t.domain; + if (t.onCallChannel) out[attrs.TOPOLOGY_ON_CALL_CHANNEL] = t.onCallChannel; + if (t.repoUrl) out[attrs.TOPOLOGY_REPO_URL] = t.repoUrl; + return out; + } + + /** Create an engine from a YAML file in the shared AgentTel config format. */ + static fromConfigFile(path: string): AgentTelEngine { + return new AgentTelEngine(loadConfig(path)); + } + /** Create a new builder. */ static builder(): AgentTelEngineBuilder { return new AgentTelEngineBuilder(); @@ -129,6 +166,19 @@ export class AgentTelEngineBuilder { return this; } + /** Load a YAML file in the shared AgentTel config format. */ + withConfigFile(path: string): this { + return this.withConfig(loadConfig(path)); + } + + /** Add or replace one operation ("METHOD /route"). */ + withOperation(name: string, op: OperationConfig): this { + const ops = normalizeOperations(this.config.operations); + ops[normalizeOperationName(name)] = op; + this.config.operations = ops; + return this; + } + withTeam(team: string): this { if (!this.config.topology) this.config.topology = {}; this.config.topology.team = team; @@ -162,3 +212,15 @@ export class AgentTelEngineBuilder { return AgentTelEngine.fromConfig(this.config); } } + +function normalizeOperations( + operations: AgentTelConfig['operations'], +): Record { + const out: Record = {}; + if (Array.isArray(operations)) { + for (const op of operations) if (op.name) out[normalizeOperationName(op.name)] = op; + } else if (operations) { + for (const [name, op] of Object.entries(operations)) out[normalizeOperationName(name)] = op; + } + return out; +} diff --git a/agenttel-node/src/enrich.ts b/agenttel-node/src/enrich.ts new file mode 100644 index 0000000..82a00f3 --- /dev/null +++ b/agenttel-node/src/enrich.ts @@ -0,0 +1,51 @@ +import type { Span } from '@opentelemetry/api'; +import * as attrs from './attributes.js'; +import type { BaselineProvider } from './baseline/provider.js'; +import type { DecisionProvider } from './operations.js'; +import { normalizeOperationName } from './operations.js'; +import type { TopologyRegistry } from './topology/registry.js'; + +/** Set topology attributes (team, tier, domain, on-call channel) on a span. */ +export function enrichTopology(span: Span, t: TopologyRegistry): void { + if (t.team) span.setAttribute(attrs.TOPOLOGY_TEAM, t.team); + if (t.tier) span.setAttribute(attrs.TOPOLOGY_TIER, t.tier); + if (t.domain) span.setAttribute(attrs.TOPOLOGY_DOMAIN, t.domain); + if (t.onCallChannel) span.setAttribute(attrs.TOPOLOGY_ON_CALL_CHANNEL, t.onCallChannel); +} + +/** + * Set baseline and decision attributes for `operationName` ("METHOD /route"). + * Returns true if anything was found for the operation. + */ +export function enrichOperation( + span: Span, + operationName: string, + sources: { baselineProvider?: BaselineProvider; decisions?: DecisionProvider }, +): boolean { + const name = normalizeOperationName(operationName); + let found = false; + + const baseline = sources.baselineProvider?.getBaseline(name); + if (baseline) { + span.setAttribute(attrs.BASELINE_LATENCY_P50_MS, baseline.latencyP50Ms); + span.setAttribute(attrs.BASELINE_LATENCY_P99_MS, baseline.latencyP99Ms); + span.setAttribute(attrs.BASELINE_ERROR_RATE, baseline.errorRate); + span.setAttribute(attrs.BASELINE_SOURCE, baseline.source); + found = true; + } + + const decision = sources.decisions?.getDecision(name); + if (decision) { + span.setAttribute(attrs.DECISION_RETRYABLE, decision.retryable); + span.setAttribute(attrs.DECISION_IDEMPOTENT, decision.idempotent); + span.setAttribute(attrs.DECISION_SAFE_TO_RESTART, decision.safeToRestart); + span.setAttribute(attrs.DECISION_ESCALATION_LEVEL, decision.escalationLevel); + span.setAttribute(attrs.DECISION_FALLBACK_AVAILABLE, !!decision.fallbackDescription); + if (decision.runbookUrl) span.setAttribute(attrs.DECISION_RUNBOOK_URL, decision.runbookUrl); + if (decision.fallbackDescription) { + span.setAttribute(attrs.DECISION_FALLBACK_DESCRIPTION, decision.fallbackDescription); + } + found = true; + } + return found; +} diff --git a/agenttel-node/src/index.ts b/agenttel-node/src/index.ts index 7eea79f..991519a 100644 --- a/agenttel-node/src/index.ts +++ b/agenttel-node/src/index.ts @@ -5,7 +5,11 @@ export type { ProcessorOptions } from './processor.js'; // Config export { loadConfig, loadConfigFromEnv, defaultConfig } from './config.js'; -export type { AgentTelConfig, TopologyConfig, DependencyConfig, ConsumerConfig, OperationConfig, SLOConfig } from './config.js'; +export { loadConfig as loadConfigFile, parseConfig } from './config.js'; +export type { AgentTelConfig, TopologyConfig, DependencyConfig, ConsumerConfig, OperationConfig, ProfileConfig, SLOConfig } from './config.js'; +export { OperationRegistry, normalizeOperationName, parseDurationMs } from './operations.js'; +export type { DecisionContext, DecisionProvider } from './operations.js'; +export { enrichOperation, enrichTopology } from './enrich.js'; // Attributes export * from './attributes.js'; diff --git a/agenttel-node/src/middleware/express.ts b/agenttel-node/src/middleware/express.ts index 19d8165..7d872da 100644 --- a/agenttel-node/src/middleware/express.ts +++ b/agenttel-node/src/middleware/express.ts @@ -1,69 +1,104 @@ -import { trace } from '@opentelemetry/api'; +import { trace, type Span } from '@opentelemetry/api'; import type { BaselineProvider } from '../baseline/provider.js'; import type { TopologyRegistry } from '../topology/registry.js'; -import * as attrs from '../attributes.js'; +import type { DecisionProvider } from '../operations.js'; +import { enrichOperation, enrichTopology } from '../enrich.js'; export interface ExpressMiddlewareOptions { + /** Shorthand: take topology, baselines and decisions from an AgentTelEngine. */ + engine?: { + topology: TopologyRegistry; + baselineProvider: BaselineProvider; + operations: DecisionProvider; + }; baselineProvider?: BaselineProvider; topology?: TopologyRegistry; + /** Decision context per operation, usually `engine.operations`. */ + operations?: DecisionProvider; routeResolver?: (req: ExpressRequest) => string; } interface ExpressRequest { method: string; route?: { path: string }; + baseUrl?: string; originalUrl: string; path: string; } interface ExpressResponse { statusCode: number; + end?: (...args: unknown[]) => unknown; } type NextFunction = (err?: unknown) => void; /** - * Express middleware that enriches the current OTel span with AgentTel attributes. + * Express middleware that enriches the active HTTP server span with AgentTel + * topology, baseline and decision attributes for "METHOD /route". + * + * Mounted with `app.use()` it runs before routing, so it enriches by request path + * first and again with the matched route template (e.g. `/orders/:id`) just + * before the response ends. * * Usage: * ```ts - * import { expressMiddleware } from '@agenttel/node'; - * app.use(expressMiddleware({ topology, baselineProvider })); + * import { AgentTelEngine, expressMiddleware } from '@agenttel/node'; + * const engine = AgentTelEngine.fromConfigFile('agenttel.yml'); + * app.use(expressMiddleware({ engine })); * ``` */ export function expressMiddleware(options: ExpressMiddlewareOptions = {}) { - return (req: ExpressRequest, _res: ExpressResponse, next: NextFunction): void => { + const topology = options.topology ?? options.engine?.topology; + const sources = { + baselineProvider: options.baselineProvider ?? options.engine?.baselineProvider, + decisions: options.operations ?? options.engine?.operations, + }; + + const resolve = (req: ExpressRequest): string => + options.routeResolver + ? options.routeResolver(req) + : req.route?.path !== undefined + ? `${req.baseUrl ?? ''}${req.route.path}` + : req.path; + + return (req: ExpressRequest, res: ExpressResponse, next: NextFunction): void => { const span = trace.getActiveSpan(); if (!span) { next(); return; } - // Resolve operation name - const routePattern = options.routeResolver - ? options.routeResolver(req) - : req.route?.path ?? req.path; - const opName = `${req.method} ${routePattern}`; + if (topology) enrichTopology(span, topology); - // Topology enrichment - if (options.topology) { - const t = options.topology; - if (t.team) span.setAttribute(attrs.TOPOLOGY_TEAM, t.team); - if (t.tier) span.setAttribute(attrs.TOPOLOGY_TIER, t.tier); - if (t.domain) span.setAttribute(attrs.TOPOLOGY_DOMAIN, t.domain); - } + const firstRoute = resolve(req); + const found = enrichOperation(span, `${req.method} ${firstRoute}`, sources); - // Baseline enrichment - if (options.baselineProvider) { - const baseline = options.baselineProvider.getBaseline(opName); - if (baseline) { - span.setAttribute(attrs.BASELINE_LATENCY_P50_MS, baseline.latencyP50Ms); - span.setAttribute(attrs.BASELINE_LATENCY_P99_MS, baseline.latencyP99Ms); - span.setAttribute(attrs.BASELINE_ERROR_RATE, baseline.errorRate); - span.setAttribute(attrs.BASELINE_SOURCE, baseline.source); - } + // Route templates are only known after routing; re-check before the span ends. + if (!found && !options.routeResolver && typeof res.end === 'function') { + const end = res.end; + res.end = function patchedEnd(this: unknown, ...args: unknown[]) { + res.end = end; + reEnrich(span, req, firstRoute, resolve, sources); + return end.apply(this, args); + }; } next(); }; } + +function reEnrich( + span: Span, + req: ExpressRequest, + firstRoute: string, + resolve: (req: ExpressRequest) => string, + sources: Parameters[2], +): void { + try { + const route = resolve(req); + if (route !== firstRoute) enrichOperation(span, `${req.method} ${route}`, sources); + } catch { + // never break the response because of telemetry + } +} diff --git a/agenttel-node/src/middleware/fastify.ts b/agenttel-node/src/middleware/fastify.ts index 0de9c26..945b34d 100644 --- a/agenttel-node/src/middleware/fastify.ts +++ b/agenttel-node/src/middleware/fastify.ts @@ -1,11 +1,20 @@ import { trace } from '@opentelemetry/api'; import type { BaselineProvider } from '../baseline/provider.js'; import type { TopologyRegistry } from '../topology/registry.js'; -import * as attrs from '../attributes.js'; +import type { DecisionProvider } from '../operations.js'; +import { enrichOperation, enrichTopology } from '../enrich.js'; export interface FastifyPluginOptions { + /** Shorthand: take topology, baselines and decisions from an AgentTelEngine. */ + engine?: { + topology: TopologyRegistry; + baselineProvider: BaselineProvider; + operations: DecisionProvider; + }; baselineProvider?: BaselineProvider; topology?: TopologyRegistry; + /** Decision context per operation, usually `engine.operations`. */ + operations?: DecisionProvider; } interface FastifyRequest { @@ -23,12 +32,14 @@ interface FastifyInstance { } /** - * Fastify plugin that enriches the current OTel span with AgentTel attributes. + * Fastify plugin that enriches the active HTTP server span with AgentTel + * topology, baseline and decision attributes for "METHOD /route". * * Usage: * ```ts - * import { fastifyPlugin } from '@agenttel/node'; - * fastify.register(fastifyPlugin, { topology, baselineProvider }); + * import { AgentTelEngine, fastifyPlugin } from '@agenttel/node'; + * const engine = AgentTelEngine.fromConfigFile('agenttel.yml'); + * fastify.register(fastifyPlugin, { engine }); * ``` */ export function fastifyPlugin( @@ -36,36 +47,22 @@ export function fastifyPlugin( options: FastifyPluginOptions, done: () => void, ): void { + const topology = options.topology ?? options.engine?.topology; + const sources = { + baselineProvider: options.baselineProvider ?? options.engine?.baselineProvider, + decisions: options.operations ?? options.engine?.operations, + }; + fastify.addHook('onRequest', (req: FastifyRequest, _reply: FastifyReply, hookDone: () => void) => { const span = trace.getActiveSpan(); - if (!span) { - hookDone(); - return; - } - - // Resolve route pattern - const routePattern = req.routeOptions?.url ?? req.url; - const opName = `${req.method} ${routePattern}`; - - // Topology enrichment - if (options.topology) { - const t = options.topology; - if (t.team) span.setAttribute(attrs.TOPOLOGY_TEAM, t.team); - if (t.tier) span.setAttribute(attrs.TOPOLOGY_TIER, t.tier); - if (t.domain) span.setAttribute(attrs.TOPOLOGY_DOMAIN, t.domain); + if (span) { + // Routing has already happened at onRequest, so the template is known. + const route = req.routeOptions?.url; + if (route) span.setAttribute('http.route', route); + const routePattern = route ?? req.url; + if (topology) enrichTopology(span, topology); + enrichOperation(span, `${req.method} ${routePattern}`, sources); } - - // Baseline enrichment - if (options.baselineProvider) { - const baseline = options.baselineProvider.getBaseline(opName); - if (baseline) { - span.setAttribute(attrs.BASELINE_LATENCY_P50_MS, baseline.latencyP50Ms); - span.setAttribute(attrs.BASELINE_LATENCY_P99_MS, baseline.latencyP99Ms); - span.setAttribute(attrs.BASELINE_ERROR_RATE, baseline.errorRate); - span.setAttribute(attrs.BASELINE_SOURCE, baseline.source); - } - } - hookDone(); }); diff --git a/agenttel-node/src/operations.ts b/agenttel-node/src/operations.ts new file mode 100644 index 0000000..ce2d157 --- /dev/null +++ b/agenttel-node/src/operations.ts @@ -0,0 +1,83 @@ +import type { OperationConfig, ProfileConfig } from './config.js'; + +/** Decision context an agent needs to act safely on an operation. */ +export interface DecisionContext { + retryable: boolean; + idempotent: boolean; + runbookUrl?: string; + fallbackDescription?: string; + escalationLevel: string; + safeToRestart: boolean; +} + +/** Looks up decision context for a named operation. */ +export interface DecisionProvider { + getDecision(operationName: string): DecisionContext | undefined; +} + +const DURATION_RE = /^\s*([0-9]*\.?[0-9]+)\s*(ms|s|m)?\s*$/i; +const PATH_PARAM_RE = /(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g; + +/** Parse a duration ("45ms", "1.5s", "2m") or a plain number (milliseconds). */ +export function parseDurationMs(value: string | number | undefined | null): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'number') return value; + const m = DURATION_RE.exec(String(value)); + if (!m) throw new Error(`agenttel: invalid duration ${JSON.stringify(value)} (expected e.g. "45ms" or 45)`); + const unit = (m[2] ?? 'ms').toLowerCase(); + return parseFloat(m[1]) * (unit === 's' ? 1000 : unit === 'm' ? 60000 : 1); +} + +/** + * Normalize an operation key to the canonical "METHOD /route" form: strips + * Spring-style [brackets], upper-cases the method, and rewrites `:param` path + * segments to `{param}` so Express, Fastify, FastAPI and Go route templates share one key. + */ +export function normalizeOperationName(name: string): string { + let n = name.trim(); + if (n.startsWith('[') && n.endsWith(']')) n = n.slice(1, -1).trim(); + const m = /^(\S+)\s+(\/.*)$/.exec(n); + if (m) n = `${m[1].toUpperCase()} ${m[2].replace(PATH_PARAM_RE, '$1{$2}')}`; + return n; +} + +/** Resolves decision context from config: explicit operation values > profile > defaults. */ +export class OperationRegistry implements DecisionProvider { + private readonly ops = new Map(); + + constructor( + operations: Record = {}, + private readonly profiles: Record = {}, + ) { + for (const [name, op] of Object.entries(operations)) this.ops.set(normalizeOperationName(name), op); + } + + /** Register or replace an operation's configuration. */ + register(name: string, op: OperationConfig): void { + this.ops.set(normalizeOperationName(name), op); + } + + getOperation(name: string): OperationConfig | undefined { + return this.ops.get(normalizeOperationName(name)); + } + + operationNames(): string[] { + return [...this.ops.keys()]; + } + + getDecision(name: string): DecisionContext | undefined { + const op = this.getOperation(name); + if (!op) return undefined; + const profile = op.profile ? this.profiles[op.profile] : undefined; + const pick = (key: K, fallback: NonNullable) => + (op[key] ?? profile?.[key] ?? fallback) as NonNullable; + return { + retryable: pick('retryable', false), + idempotent: pick('idempotent', false), + runbookUrl: op.runbookUrl ?? profile?.runbookUrl, + fallbackDescription: op.fallbackDescription ?? profile?.fallbackDescription, + escalationLevel: pick('escalationLevel', 'auto_resolve'), + safeToRestart: pick('safeToRestart', true), + }; + } +} diff --git a/agenttel-node/src/processor.ts b/agenttel-node/src/processor.ts index 90f7d07..04511f3 100644 --- a/agenttel-node/src/processor.ts +++ b/agenttel-node/src/processor.ts @@ -9,6 +9,8 @@ import { ErrorClassifier } from './error/classifier.js'; import { SLOTracker } from './slo/tracker.js'; import { TopologyRegistry } from './topology/registry.js'; import { AgentTelEventEmitter } from './events/emitter.js'; +import { enrichOperation, enrichTopology } from './enrich.js'; +import { normalizeOperationName, type DecisionProvider } from './operations.js'; export interface ProcessorOptions { topology?: TopologyRegistry; @@ -18,6 +20,8 @@ export interface ProcessorOptions { sloTracker?: SLOTracker; errorClassifier?: ErrorClassifier; eventEmitter?: AgentTelEventEmitter; + /** Decision context per operation (retryable, runbook, escalation, ...). */ + operations?: DecisionProvider; } /** OTel SpanProcessor that enriches spans with AgentTel metadata. */ @@ -29,6 +33,7 @@ export class AgentTelSpanProcessor implements SpanProcessor { private readonly sloTracker?: SLOTracker; private readonly errorClassifier?: ErrorClassifier; private readonly eventEmitter?: AgentTelEventEmitter; + private readonly operations?: DecisionProvider; constructor(options: ProcessorOptions = {}) { this.topology = options.topology; @@ -38,14 +43,13 @@ export class AgentTelSpanProcessor implements SpanProcessor { this.sloTracker = options.sloTracker; this.errorClassifier = options.errorClassifier; this.eventEmitter = options.eventEmitter; + this.operations = options.operations; } onStart(span: Span, _parentContext: Context): void { // Enrich with topology metadata if (this.topology) { - if (this.topology.team) span.setAttribute(attrs.TOPOLOGY_TEAM, this.topology.team); - if (this.topology.tier) span.setAttribute(attrs.TOPOLOGY_TIER, this.topology.tier); - if (this.topology.domain) span.setAttribute(attrs.TOPOLOGY_DOMAIN, this.topology.domain); + enrichTopology(span, this.topology); const deps = this.topology.dependencies(); if (deps.length > 0) { @@ -57,21 +61,16 @@ export class AgentTelSpanProcessor implements SpanProcessor { } } - // Enrich with baseline data - const opName = (span as unknown as { name: string }).name; - if (this.baselineProvider && opName) { - const baseline = this.baselineProvider.getBaseline(opName); - if (baseline) { - span.setAttribute(attrs.BASELINE_LATENCY_P50_MS, baseline.latencyP50Ms); - span.setAttribute(attrs.BASELINE_LATENCY_P99_MS, baseline.latencyP99Ms); - span.setAttribute(attrs.BASELINE_ERROR_RATE, baseline.errorRate); - span.setAttribute(attrs.BASELINE_SOURCE, baseline.source); - } + // Enrich spans already named after a configured operation. HTTP server spans are + // named before routing (e.g. "POST"); the framework middleware handles those. + const opName = (span as unknown as { name?: string }).name; + if (opName) { + enrichOperation(span, opName, { baselineProvider: this.baselineProvider, decisions: this.operations }); } } onEnd(span: ReadableSpan): void { - const opName = span.name; + const opName = operationNameOf(span); const durationMs = (span.endTime[0] - span.startTime[0]) * 1000 + (span.endTime[1] - span.startTime[1]) / 1e6; const isError = span.status?.code === 2; // StatusCode.ERROR @@ -132,3 +131,13 @@ export class AgentTelSpanProcessor implements SpanProcessor { // no-op } } + +/** "METHOD /route" for HTTP server spans with a route, otherwise the span name. */ +function operationNameOf(span: ReadableSpan): string { + const route = span.attributes?.['http.route']; + const method = span.attributes?.['http.request.method'] ?? span.attributes?.['http.method']; + if (typeof route === 'string' && typeof method === 'string') { + return normalizeOperationName(`${method} ${route}`); + } + return normalizeOperationName(span.name); +} diff --git a/agenttel-node/tests/config.test.ts b/agenttel-node/tests/config.test.ts index 30e5975..c3186d0 100644 --- a/agenttel-node/tests/config.test.ts +++ b/agenttel-node/tests/config.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { defaultConfig, loadConfigFromEnv } from '../src/config.js'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { defaultConfig, loadConfigFromEnv, parseConfig, type OperationConfig } from '../src/config.js'; +import { normalizeOperationName, parseDurationMs } from '../src/operations.js'; +import { AgentTelEngine } from '../src/engine.js'; describe('Config', () => { it('default config has sensible defaults', () => { @@ -44,3 +46,77 @@ describe('Config', () => { }); }); }); + +describe('shared config format', () => { + it('parses the canonical agenttel: document', () => { + const config = parseConfig({ + agenttel: { + topology: { team: 'payments', 'on-call-channel': '#oncall', 'service-name': 'svc' }, + operations: { + '[POST /api/payments]': { + 'expected-latency-p50': '45ms', + 'expected-latency-p99': '1.5s', + 'expected-error-rate': 0.001, + retryable: true, + 'runbook-url': 'https://runbooks/payments', + }, + 'GET /orders/:id': { 'expected-latency-p50': 15 }, + }, + baselines: { 'rolling-window-size': 500 }, + 'anomaly-detection': { 'z-score-threshold': 2.5 }, + slo: { availability: { target: 0.999, type: 'availability' } }, + }, + }); + expect(config.topology?.onCallChannel).toBe('#oncall'); + expect(config.topology?.serviceName).toBe('svc'); + const ops = config.operations as Record; + expect(Object.keys(ops)).toEqual(['POST /api/payments', 'GET /orders/{id}']); + expect(ops['POST /api/payments'].expectedLatencyP50).toBe('45ms'); + expect(ops['POST /api/payments'].retryable).toBe(true); + expect(config.rollingWindowSize).toBe(500); + expect(config.anomalyZScoreThreshold).toBe(2.5); + expect(config.slos?.[0]).toMatchObject({ name: 'availability', target: 0.999 }); + + const engine = AgentTelEngine.fromConfig(config); + expect(engine.baselineProvider.getBaseline('POST /api/payments')?.latencyP99Ms).toBe(1500); + expect(engine.baselineProvider.getBaseline('GET /orders/{id}')?.latencyP50Ms).toBe(15); + expect(engine.operations.getDecision('[POST /api/payments]')).toMatchObject({ + retryable: true, + runbookUrl: 'https://runbooks/payments', + escalationLevel: 'auto_resolve', + }); + }); + + it('still loads the pre-0.4.0 Node dialect, with a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = parseConfig({ + topology: { team: 't', on_call_channel: '#c' }, + operations: [{ name: 'POST /p', baseline_latency_p50_ms: 45, baseline_latency_p99_ms: 200 }], + rolling_window_size: 50, + anomaly_zscore_threshold: 2, + }); + expect(config.topology?.onCallChannel).toBe('#c'); + expect(config.rollingWindowSize).toBe(50); + expect(config.anomalyZScoreThreshold).toBe(2); + const engine = AgentTelEngine.fromConfig(config); + expect(engine.baselineProvider.getBaseline('POST /p')?.latencyP50Ms).toBe(45); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('accepts programmatic operation arrays (pre-0.4.0 API)', () => { + const engine = AgentTelEngine.fromConfig({ + operations: [{ name: 'GET /x', baselineLatencyP50Ms: 10, baselineLatencyP99Ms: 20 }], + }); + expect(engine.baselineProvider.getBaseline('GET /x')?.latencyP50Ms).toBe(10); + }); + + it('parses durations and normalizes operation names', () => { + expect(parseDurationMs('45ms')).toBe(45); + expect(parseDurationMs('2s')).toBe(2000); + expect(parseDurationMs(12)).toBe(12); + expect(() => parseDurationMs('fast')).toThrow(); + expect(normalizeOperationName('[post /a/:id]')).toBe('POST /a/{id}'); + expect(normalizeOperationName('process-payment')).toBe('process-payment'); + }); +}); diff --git a/agenttel-node/tests/integration.test.ts b/agenttel-node/tests/integration.test.ts new file mode 100644 index 0000000..1ea96e8 --- /dev/null +++ b/agenttel-node/tests/integration.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +// End-to-end, mirroring the Spring Boot EnrichmentTest: a real HTTP server with OTel +// auto-instrumentation and the *built* package (plain `node`, no bundler/tsx), one +// request, then assert the HTTP server span is enriched. + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const script = path.join(root, 'tests', 'integration', 'server.cjs'); + +interface ServerSpan { + name: string; + route?: string; + attributes: Record; + resource: Record; +} + +function run(framework: string): ServerSpan[] { + const out = execFileSync(process.execPath, [script, framework], { cwd: root, encoding: 'utf-8' }); + return JSON.parse(out) as ServerSpan[]; +} + +// `npm test` builds first (pretest); the server script loads dist/ via package self-reference. +describe.each(['express', 'fastify'])('%s server span', (framework) => { + let spans: ServerSpan[]; + beforeAll(() => { + spans = run(framework); + }, 60_000); + + it('has topology, static baseline and decision attributes', () => { + const span = spans.find((s) => s.route === '/api/payments'); + expect(span, JSON.stringify(spans)).toBeDefined(); + const a = span!.attributes; + expect(a['agenttel.topology.team']).toBe('payments-platform'); + expect(a['agenttel.topology.on_call_channel']).toBe('#payments-oncall'); + expect(a['agenttel.baseline.latency_p50_ms']).toBe(45); + expect(a['agenttel.baseline.latency_p99_ms']).toBe(200); + expect(a['agenttel.baseline.error_rate']).toBe(0.001); + expect(a['agenttel.decision.retryable']).toBe(true); + expect(a['agenttel.decision.idempotent']).toBe(true); + expect(a['agenttel.decision.runbook_url']).toBe('https://wiki.example.com/runbooks/payments'); + expect(a['agenttel.decision.escalation_level']).toBe('page_oncall'); // from profile + expect(span!.resource['agenttel.topology.team']).toBe('payments-platform'); + }); + + it('matches parameterized routes by template', () => { + const span = spans.find((s) => s.route === '/api/payments/:id'); + expect(span, JSON.stringify(spans)).toBeDefined(); + expect(span!.attributes['agenttel.baseline.latency_p50_ms']).toBe(15); + expect(span!.attributes['agenttel.decision.retryable']).toBe(false); + }); +}); diff --git a/agenttel-node/tests/integration/agenttel.yml b/agenttel-node/tests/integration/agenttel.yml new file mode 100644 index 0000000..c52f84d --- /dev/null +++ b/agenttel-node/tests/integration/agenttel.yml @@ -0,0 +1,23 @@ +agenttel: + topology: + service-name: payment-service + team: payments-platform + tier: critical + domain: commerce + on-call-channel: "#payments-oncall" + profiles: + critical-write: + retryable: false + escalation-level: page_oncall + operations: + "[POST /api/payments]": + profile: critical-write + expected-latency-p50: 45ms + expected-latency-p99: 200ms + expected-error-rate: 0.001 + retryable: true + idempotent: true + runbook-url: https://wiki.example.com/runbooks/payments + "GET /api/payments/{id}": + expected-latency-p50: 15 + expected-latency-p99: 80 diff --git a/agenttel-node/tests/integration/server.cjs b/agenttel-node/tests/integration/server.cjs new file mode 100644 index 0000000..ae1d751 --- /dev/null +++ b/agenttel-node/tests/integration/server.cjs @@ -0,0 +1,72 @@ +// Real HTTP server + OTel auto-instrumentation + @agenttel/node, run as plain CommonJS. +// Usage: node server.cjs express|fastify [config.yml] +// Prints the agenttel.* attributes of the HTTP server spans as JSON on stdout. +'use strict'; + +const path = require('node:path'); +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const { SimpleSpanProcessor, InMemorySpanExporter } = require('@opentelemetry/sdk-trace-base'); +const { Resource } = require('@opentelemetry/resources'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express'); +const { SpanKind } = require('@opentelemetry/api'); +const { AgentTelEngine, expressMiddleware, fastifyPlugin } = require('@agenttel/node'); + +const framework = process.argv[2] || 'express'; +const configPath = process.argv[3] || path.join(__dirname, 'agenttel.yml'); + +const engine = AgentTelEngine.fromConfigFile(configPath); +const exporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ + resource: new Resource(engine.resourceAttributes()), + spanProcessors: [engine.spanProcessor, new SimpleSpanProcessor(exporter)], +}); +provider.register(); +registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()] }); + +async function startExpress() { + const express = require('express'); + const app = express(); + app.use(expressMiddleware({ engine })); + app.post('/api/payments', (_req, res) => res.json({ ok: true })); + app.get('/api/payments/:id', (req, res) => res.json({ id: req.params.id })); + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + return { port: server.address().port, close: () => new Promise((r) => server.close(r)) }; +} + +async function startFastify() { + const app = require('fastify')(); + await app.register(fastifyPlugin, { engine }); + app.post('/api/payments', async () => ({ ok: true })); + app.get('/api/payments/:id', async (req) => ({ id: req.params.id })); + await app.listen({ port: 0, host: '127.0.0.1' }); + return { port: app.server.address().port, close: () => app.close() }; +} + +(async () => { + const server = framework === 'fastify' ? await startFastify() : await startExpress(); + const base = `http://127.0.0.1:${server.port}`; + await fetch(`${base}/api/payments`, { method: 'POST' }); + await fetch(`${base}/api/payments/pay_123`); + await server.close(); + await provider.forceFlush(); + + const spans = exporter + .getFinishedSpans() + .filter((s) => s.kind === SpanKind.SERVER) + .map((s) => ({ + name: s.name, + route: s.attributes['http.route'], + attributes: Object.fromEntries(Object.entries(s.attributes).filter(([k]) => k.startsWith('agenttel.'))), + resource: Object.fromEntries( + Object.entries(s.resource.attributes).filter(([k]) => k.startsWith('agenttel.')), + ), + })); + process.stdout.write(JSON.stringify(spans)); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/agenttel-node/tsconfig.cjs.json b/agenttel-node/tsconfig.cjs.json index 0b68e5e..a6eec10 100644 --- a/agenttel-node/tsconfig.cjs.json +++ b/agenttel-node/tsconfig.cjs.json @@ -2,7 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "module": "CommonJS", - "moduleResolution": "node", + "moduleResolution": "node10", + "declaration": false, + "declarationMap": false, "outDir": "./dist/cjs" } } diff --git a/agenttel-node/tsconfig.json b/agenttel-node/tsconfig.json index b0b1d1b..f57965f 100644 --- a/agenttel-node/tsconfig.json +++ b/agenttel-node/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "declaration": true, "declarationMap": true, "sourceMap": true, diff --git a/examples/express-example/package.json b/examples/express-example/package.json index 9adb083..c15cb65 100644 --- a/examples/express-example/package.json +++ b/examples/express-example/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node --loader ts-node/esm src/index.ts", + "start": "tsx src/index.ts", "build": "tsc", "dev": "tsx watch src/index.ts" }, @@ -16,7 +16,10 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", "@opentelemetry/semantic-conventions": "^1.28.0", - "express": "^4.21.0" + "express": "^4.21.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/instrumentation-http": "^0.57.0", + "@opentelemetry/instrumentation-express": "^0.47.0" }, "devDependencies": { "@types/express": "^5.0.0", diff --git a/examples/express-example/src/index.ts b/examples/express-example/src/index.ts index db8a759..c51a289 100644 --- a/examples/express-example/src/index.ts +++ b/examples/express-example/src/index.ts @@ -1,47 +1,13 @@ +import { engine, sdk } from './telemetry.js'; // must be the first import import express from 'express'; import { trace } from '@opentelemetry/api'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { Resource } from '@opentelemetry/resources'; -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; -import { - AgentTelEngineBuilder, - expressMiddleware, - loadConfig, -} from '@agenttel/node'; - -// Load AgentTel config -const config = loadConfig('agenttel.yml'); - -// Set up OTel -const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'; -const exporter = new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }); - -const sdk = new NodeSDK({ - resource: new Resource({ - [ATTR_SERVICE_NAME]: config.topology?.serviceName ?? 'express-payment-service', - [ATTR_SERVICE_VERSION]: '1.0.0', - }), - traceExporter: exporter, -}); - -sdk.start(); - -// Build AgentTel engine -const engine = new AgentTelEngineBuilder() - .withTeam(config.topology?.team ?? 'payments-platform') - .withTier(config.topology?.tier ?? 'critical') - .withDomain(config.topology?.domain ?? 'commerce') - .build(); +import { expressMiddleware } from '@agenttel/node'; const app = express(); app.use(express.json()); -// Apply AgentTel middleware -app.use(expressMiddleware({ - baselineProvider: engine.baselineProvider, - topology: engine.topologyRegistry, -})); +// Enrich HTTP server spans with topology, baselines and decisions (retryable, runbook, ...) +app.use(expressMiddleware({ engine })); // Routes app.post('/api/payments', (_req, res) => { diff --git a/examples/express-example/src/telemetry.ts b/examples/express-example/src/telemetry.ts new file mode 100644 index 0000000..9ece6f6 --- /dev/null +++ b/examples/express-example/src/telemetry.ts @@ -0,0 +1,27 @@ +// OpenTelemetry + AgentTel setup. Imported first by index.ts so the HTTP +// instrumentation patches Node's http module before the web framework loads. +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { BatchSpanProcessor, ConsoleSpanExporter, type SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; +import { Resource } from '@opentelemetry/resources'; +import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { AgentTelEngine } from '@agenttel/node'; + +// Topology, baselines and decisions come from agenttel.yml (shared AgentTel format). +export const engine = AgentTelEngine.fromConfigFile(process.env.AGENTTEL_CONFIG ?? 'agenttel.yml'); + +const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'; +const exporter: SpanExporter = + process.env.OTEL_TRACES_EXPORTER === 'console' + ? new ConsoleSpanExporter() + : new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }); + +export const sdk = new NodeSDK({ + // service.name and agenttel.topology.* on the resource + resource: new Resource({ ...engine.resourceAttributes(), [ATTR_SERVICE_VERSION]: '1.0.0' }), + spanProcessors: [engine.spanProcessor, new BatchSpanProcessor(exporter)], + instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()], +}); +sdk.start(); diff --git a/examples/fastify-example/package.json b/examples/fastify-example/package.json index 7f52a1e..491574d 100644 --- a/examples/fastify-example/package.json +++ b/examples/fastify-example/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node --loader ts-node/esm src/index.ts", + "start": "tsx src/index.ts", "build": "tsc", "dev": "tsx watch src/index.ts" }, @@ -16,7 +16,9 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", "@opentelemetry/semantic-conventions": "^1.28.0", - "fastify": "^5.2.0" + "fastify": "^5.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/instrumentation-http": "^0.57.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/fastify-example/src/index.ts b/examples/fastify-example/src/index.ts index 751c89a..c6e5830 100644 --- a/examples/fastify-example/src/index.ts +++ b/examples/fastify-example/src/index.ts @@ -1,46 +1,12 @@ +import { engine, sdk } from './telemetry.js'; // must be the first import import Fastify from 'fastify'; import { trace } from '@opentelemetry/api'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { Resource } from '@opentelemetry/resources'; -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; -import { - AgentTelEngineBuilder, - fastifyPlugin, - loadConfig, -} from '@agenttel/node'; - -// Load AgentTel config -const config = loadConfig('agenttel.yml'); - -// Set up OTel -const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'; -const exporter = new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }); - -const sdk = new NodeSDK({ - resource: new Resource({ - [ATTR_SERVICE_NAME]: config.topology?.serviceName ?? 'fastify-payment-service', - [ATTR_SERVICE_VERSION]: '1.0.0', - }), - traceExporter: exporter, -}); - -sdk.start(); - -// Build AgentTel engine -const engine = new AgentTelEngineBuilder() - .withTeam(config.topology?.team ?? 'payments-platform') - .withTier(config.topology?.tier ?? 'critical') - .withDomain(config.topology?.domain ?? 'commerce') - .build(); +import { fastifyPlugin } from '@agenttel/node'; const app = Fastify({ logger: true }); -// Apply AgentTel middleware via Fastify plugin -app.register(fastifyPlugin, { - baselineProvider: engine.baselineProvider, - topology: engine.topologyRegistry, -}); +// Enrich HTTP server spans with topology, baselines and decisions (retryable, runbook, ...) +app.register(fastifyPlugin, { engine }); // Routes app.post('/api/payments', async (_request, reply) => { diff --git a/examples/fastify-example/src/telemetry.ts b/examples/fastify-example/src/telemetry.ts new file mode 100644 index 0000000..a749e6f --- /dev/null +++ b/examples/fastify-example/src/telemetry.ts @@ -0,0 +1,27 @@ +// OpenTelemetry + AgentTel setup. Imported first by index.ts so the HTTP +// instrumentation patches Node's http module before the web framework loads. +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { BatchSpanProcessor, ConsoleSpanExporter, type SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +import { Resource } from '@opentelemetry/resources'; +import { ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { AgentTelEngine } from '@agenttel/node'; + +// Topology, baselines and decisions come from agenttel.yml (shared AgentTel format). +export const engine = AgentTelEngine.fromConfigFile(process.env.AGENTTEL_CONFIG ?? 'agenttel.yml'); + +const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'; +const exporter: SpanExporter = + process.env.OTEL_TRACES_EXPORTER === 'console' + ? new ConsoleSpanExporter() + : new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }); + +export const sdk = new NodeSDK({ + // service.name and agenttel.topology.* on the resource + resource: new Resource({ ...engine.resourceAttributes(), [ATTR_SERVICE_VERSION]: '1.0.0' }), + spanProcessors: [engine.spanProcessor, new BatchSpanProcessor(exporter)], + instrumentations: [new HttpInstrumentation()], +}); +sdk.start(); From 8701c10362f0d1647bde3e3e39739b018018c5b1 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Fri, 16 Oct 2026 16:32:54 +0100 Subject: [PATCH 3/7] Go SDK: config operations become baselines and decisions - Builder.Build() registers static baselines and decision context from YAML operations (explicit values > profile > defaults) - Load the shared agenttel.yml format; the pre-0.4.0 flat camelCase dialect still loads with a warning - New operations package (Registry, Decision, Normalize, ParseDurationMs) and enrich package shared by the processor and middleware - net/http, Gin and gRPC middleware set decision attributes and accept WithEngine(engine); net/http records http.route from Go 1.22+ ServeMux patterns and matches parameterized routes after routing - Engine.Resource()/ResourceAttributes() put topology on the resource - Fix go-service-example and gin-example to compile and run against the real API with otelhttp/otelgin - Integration test: httptest server with otelhttp + AgentTel middleware Signed-off-by: Rohit Kumar --- agenttel-go/agenttel.go | 132 ++++++++- agenttel-go/baseline/provider.go | 5 +- agenttel-go/config.go | 257 ++++++++++++++++-- agenttel-go/config_test.go | 89 ++++++ agenttel-go/enrich/enrich.go | 67 +++++ agenttel-go/go.mod | 2 + agenttel-go/go.sum | 4 + agenttel-go/middleware/gin/middleware.go | 78 +++--- agenttel-go/middleware/grpc/interceptor.go | 50 ++-- .../middleware/http/integration_test.go | 138 ++++++++++ agenttel-go/middleware/http/middleware.go | 112 ++++---- agenttel-go/operations/operations.go | 95 +++++++ agenttel-go/operations/operations_test.go | 35 +++ agenttel-go/processor/processor.go | 63 +++-- examples/gin-example/agenttel.yml | 6 +- examples/gin-example/go.mod | 63 ++++- examples/gin-example/go.sum | 146 ++++++++++ examples/gin-example/main.go | 74 ++--- examples/go-service-example/README.md | 29 +- examples/go-service-example/agenttel.yml | 2 +- examples/go-service-example/go.mod | 37 ++- examples/go-service-example/go.sum | 76 ++++++ examples/go-service-example/main.go | 69 ++--- 23 files changed, 1331 insertions(+), 298 deletions(-) create mode 100644 agenttel-go/enrich/enrich.go create mode 100644 agenttel-go/middleware/http/integration_test.go create mode 100644 agenttel-go/operations/operations.go create mode 100644 agenttel-go/operations/operations_test.go create mode 100644 examples/gin-example/go.sum create mode 100644 examples/go-service-example/go.sum diff --git a/agenttel-go/agenttel.go b/agenttel-go/agenttel.go index 0d6119c..b90571d 100644 --- a/agenttel-go/agenttel.go +++ b/agenttel-go/agenttel.go @@ -11,16 +11,28 @@ // Build() // // tp := sdktrace.NewTracerProvider( +// sdktrace.WithResource(engine.Resource()), // sdktrace.WithSpanProcessor(engine.SpanProcessor()), +// sdktrace.WithBatcher(exporter), // ) +// +// handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server") package agenttel import ( + "fmt" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" + "go.agenttel.dev/agenttel-go/anomaly" + "go.agenttel.dev/agenttel-go/attributes" "go.agenttel.dev/agenttel-go/baseline" "go.agenttel.dev/agenttel-go/errorclass" "go.agenttel.dev/agenttel-go/events" "go.agenttel.dev/agenttel-go/models" + "go.agenttel.dev/agenttel-go/operations" "go.agenttel.dev/agenttel-go/processor" "go.agenttel.dev/agenttel-go/slo" "go.agenttel.dev/agenttel-go/topology" @@ -37,7 +49,41 @@ type Engine struct { SLOTracker *slo.Tracker ErrorClassifier *errorclass.Classifier EventEmitter *events.Emitter - spanProcessor *processor.AgentTelSpanProcessor + // Operations holds per-operation decision context (retryable, runbook, escalation, ...). + Operations *operations.Registry + spanProcessor *processor.AgentTelSpanProcessor +} + +// ResourceAttributes returns service.name (if configured) and the topology as +// OTel resource attributes. +func (e *Engine) ResourceAttributes() []attribute.KeyValue { + var kv []attribute.KeyValue + if e.Config.Topology.ServiceName != "" { + kv = append(kv, attribute.String("service.name", e.Config.Topology.ServiceName)) + } + t := e.TopologyRegistry + for k, v := range map[string]string{ + attributes.TopologyTeam: t.Team(), + attributes.TopologyTier: t.Tier(), + attributes.TopologyDomain: t.Domain(), + attributes.TopologyOnCallChannel: t.OnCallChannel(), + attributes.TopologyRepoURL: t.RepoURL(), + } { + if v != "" { + kv = append(kv, attribute.String(k, v)) + } + } + return kv +} + +// Resource returns the default OTel resource (host, process, OTEL_* env) +// merged with ResourceAttributes, for sdktrace.WithResource. +func (e *Engine) Resource() *resource.Resource { + r, err := resource.Merge(resource.Default(), resource.NewSchemaless(e.ResourceAttributes()...)) + if err != nil { + return resource.NewSchemaless(e.ResourceAttributes()...) + } + return r } // SpanProcessor returns the OTel SpanProcessor for use with TracerProvider. @@ -155,10 +201,20 @@ func (b *Builder) Build() (*Engine, error) { cfg.Baselines.RollingMinSamples, ) + // Static baselines and decisions from config operations. Baselines passed + // to WithStaticBaselines take precedence over the config file. + staticBaselines, decisions, err := buildOperations(cfg) + if err != nil { + return nil, err + } + for name, bl := range b.staticBaselines { + staticBaselines[operations.Normalize(name)] = bl + } + var baseProvider baseline.Provider - if len(b.staticBaselines) > 0 { + if len(staticBaselines) > 0 { baseProvider = baseline.NewCompositeProvider( - baseline.NewStaticProvider(b.staticBaselines), + baseline.NewStaticProvider(staticBaselines), rollingProvider, ) } else { @@ -171,6 +227,14 @@ func (b *Builder) Build() (*Engine, error) { // SLO sloTracker := slo.NewTracker() + for name, s := range cfg.SLO { + sloTracker.Register(models.SLODefinition{ + Name: name, + OperationName: s.Operation, + Type: s.Type, + Target: s.Target, + }) + } for _, def := range b.sloDefinitions { sloTracker.Register(def) } @@ -191,6 +255,7 @@ func (b *Builder) Build() (*Engine, error) { processor.WithErrorClassifier(errorClassifier), processor.WithTopology(topoReg), processor.WithEventEmitter(eventEmitter), + processor.WithDecisions(decisions), ) return &Engine{ @@ -203,6 +268,67 @@ func (b *Builder) Build() (*Engine, error) { SLOTracker: sloTracker, ErrorClassifier: errorClassifier, EventEmitter: eventEmitter, + Operations: decisions, spanProcessor: sp, }, nil } + +// buildOperations turns config operations into static baselines and decision +// context: explicit operation values > referenced profile > defaults. +func buildOperations(cfg AgentTelConfig) (map[string]models.OperationBaseline, *operations.Registry, error) { + baselines := make(map[string]models.OperationBaseline) + decisions := operations.NewRegistry() + for rawName, op := range cfg.Operations { + name := operations.Normalize(rawName) + + p50, hasP50, err := operations.ParseDurationMs(op.ExpectedLatencyP50) + if err != nil { + return nil, nil, fmt.Errorf("operation %q: %w", rawName, err) + } + p99, hasP99, err := operations.ParseDurationMs(op.ExpectedLatencyP99) + if err != nil { + return nil, nil, fmt.Errorf("operation %q: %w", rawName, err) + } + if hasP50 || hasP99 || op.ExpectedErrorRate > 0 { + baselines[name] = models.OperationBaseline{ + OperationName: name, + LatencyP50Ms: p50, + LatencyP99Ms: p99, + ErrorRate: op.ExpectedErrorRate, + Source: "static", + UpdatedAt: time.Now(), + } + } + + profile := cfg.Profiles[op.Profile] + decisions.Register(name, operations.Decision{ + Retryable: pickBool(op.Retryable, profile.Retryable, false), + Idempotent: pickBool(op.Idempotent, profile.Idempotent, false), + RunbookURL: pickString(op.RunbookURL, profile.RunbookURL, ""), + FallbackDescription: pickString(op.FallbackDescription, profile.FallbackDescription, ""), + EscalationLevel: pickString(op.EscalationLevel, profile.EscalationLevel, "auto_resolve"), + SafeToRestart: pickBool(op.SafeToRestart, profile.SafeToRestart, true), + }) + } + return baselines, decisions, nil +} + +func pickBool(op, profile *bool, def bool) bool { + if op != nil { + return *op + } + if profile != nil { + return *profile + } + return def +} + +func pickString(op, profile, def string) string { + if op != "" { + return op + } + if profile != "" { + return profile + } + return def +} diff --git a/agenttel-go/baseline/provider.go b/agenttel-go/baseline/provider.go index 54e1093..fe82217 100644 --- a/agenttel-go/baseline/provider.go +++ b/agenttel-go/baseline/provider.go @@ -3,6 +3,7 @@ package baseline import ( "go.agenttel.dev/agenttel-go/models" + "go.agenttel.dev/agenttel-go/operations" ) // Provider returns baseline data for a named operation. @@ -19,13 +20,13 @@ type StaticProvider struct { func NewStaticProvider(baselines map[string]models.OperationBaseline) *StaticProvider { m := make(map[string]models.OperationBaseline, len(baselines)) for k, v := range baselines { - m[k] = v + m[operations.Normalize(k)] = v } return &StaticProvider{baselines: m} } // GetBaseline returns the baseline for the given operation, if configured. func (s *StaticProvider) GetBaseline(operationName string) (models.OperationBaseline, bool) { - b, ok := s.baselines[operationName] + b, ok := s.baselines[operations.Normalize(operationName)] return b, ok } diff --git a/agenttel-go/config.go b/agenttel-go/config.go index 2132b1c..b7effe5 100644 --- a/agenttel-go/config.go +++ b/agenttel-go/config.go @@ -1,30 +1,57 @@ package agenttel import ( + "fmt" + "log" "os" + "regexp" + "sort" "strings" "gopkg.in/yaml.v3" + + "go.agenttel.dev/agenttel-go/operations" ) // AgentTelConfig is the top-level configuration matching agenttel.yml. +// +// The file format is shared by every AgentTel SDK (JVM, Go, Node.js, Python): +// +// agenttel: +// topology: +// team: payments-platform +// tier: critical +// on-call-channel: "#payments-oncall" +// operations: +// "[POST /api/payments]": # brackets optional +// expected-latency-p50: 45ms # duration string, or a number in ms +// expected-latency-p99: 200ms +// expected-error-rate: 0.001 +// retryable: true +// runbook-url: https://wiki.example.com/runbooks/payments +// +// The pre-0.4.0 Go dialect (no "agenttel:" root, camelCase keys) still loads, +// with a deprecation warning. type AgentTelConfig struct { Enabled bool `yaml:"enabled"` Topology TopologyConfig `yaml:"topology"` Dependencies []DependencyConfig `yaml:"dependencies"` Consumers []ConsumerConfig `yaml:"consumers"` + Profiles map[string]ProfileConfig `yaml:"profiles"` Operations map[string]OperationConfig `yaml:"operations"` Baselines BaselineConfig `yaml:"baselines"` - AnomalyDetection AnomalyDetectionConfig `yaml:"anomalyDetection"` + AnomalyDetection AnomalyDetectionConfig `yaml:"anomaly-detection"` + SLO map[string]SLOConfig `yaml:"slo"` } // TopologyConfig describes service ownership metadata. type TopologyConfig struct { + ServiceName string `yaml:"service-name"` Team string `yaml:"team"` Tier string `yaml:"tier"` Domain string `yaml:"domain"` - OnCallChannel string `yaml:"onCallChannel"` - RepoURL string `yaml:"repoUrl"` + OnCallChannel string `yaml:"on-call-channel"` + RepoURL string `yaml:"repo-url"` } // DependencyConfig declares a service dependency. @@ -33,42 +60,62 @@ type DependencyConfig struct { Type string `yaml:"type"` Criticality string `yaml:"criticality"` Protocol string `yaml:"protocol"` - TimeoutMs int `yaml:"timeoutMs"` - CircuitBreaker bool `yaml:"circuitBreaker"` + TimeoutMs int `yaml:"timeout-ms"` + CircuitBreaker bool `yaml:"circuit-breaker"` Fallback string `yaml:"fallback"` - HealthEndpoint string `yaml:"healthEndpoint"` + HealthEndpoint string `yaml:"health-endpoint"` } // ConsumerConfig declares a downstream consumer. type ConsumerConfig struct { Name string `yaml:"name"` Pattern string `yaml:"pattern"` - SLALatencyMs int `yaml:"slaLatencyMs"` + SLALatencyMs int `yaml:"sla-latency-ms"` +} + +// ProfileConfig holds decision defaults that operations reference by name. +type ProfileConfig struct { + Retryable *bool `yaml:"retryable"` + Idempotent *bool `yaml:"idempotent"` + RunbookURL string `yaml:"runbook-url"` + FallbackDescription string `yaml:"fallback-description"` + EscalationLevel string `yaml:"escalation-level"` + SafeToRestart *bool `yaml:"safe-to-restart"` } -// OperationConfig provides per-operation configuration. +// OperationConfig provides per-operation configuration. Explicit values +// override the referenced profile. Latencies are durations ("45ms", "1s") or +// plain numbers of milliseconds. type OperationConfig struct { - Profile string `yaml:"profile"` - ExpectedLatencyP50 string `yaml:"expectedLatencyP50"` - ExpectedLatencyP99 string `yaml:"expectedLatencyP99"` - ExpectedErrorRate float64 `yaml:"expectedErrorRate"` - Retryable *bool `yaml:"retryable"` - Idempotent *bool `yaml:"idempotent"` - RunbookURL string `yaml:"runbookUrl"` - EscalationLevel string `yaml:"escalationLevel"` - SafeToRestart *bool `yaml:"safeToRestart"` + Profile string `yaml:"profile"` + ExpectedLatencyP50 string `yaml:"expected-latency-p50"` + ExpectedLatencyP99 string `yaml:"expected-latency-p99"` + ExpectedErrorRate float64 `yaml:"expected-error-rate"` + Retryable *bool `yaml:"retryable"` + Idempotent *bool `yaml:"idempotent"` + RunbookURL string `yaml:"runbook-url"` + FallbackDescription string `yaml:"fallback-description"` + EscalationLevel string `yaml:"escalation-level"` + SafeToRestart *bool `yaml:"safe-to-restart"` } // BaselineConfig configures the rolling baseline provider. type BaselineConfig struct { - RollingWindowSize int `yaml:"rollingWindowSize"` - RollingMinSamples int `yaml:"rollingMinSamples"` + RollingWindowSize int `yaml:"rolling-window-size"` + RollingMinSamples int `yaml:"rolling-min-samples"` } // AnomalyDetectionConfig configures anomaly detection. type AnomalyDetectionConfig struct { Enabled bool `yaml:"enabled"` - ZScoreThreshold float64 `yaml:"zScoreThreshold"` + ZScoreThreshold float64 `yaml:"z-score-threshold"` +} + +// SLOConfig declares a service level objective, keyed by name under "slo". +type SLOConfig struct { + Target float64 `yaml:"target"` + Type string `yaml:"type"` + Operation string `yaml:"operation"` } // DefaultConfig returns a configuration with sensible defaults. @@ -88,18 +135,43 @@ func DefaultConfig() AgentTelConfig { // LoadConfig reads an AgentTelConfig from a YAML file, then applies env overrides. func LoadConfig(path string) (AgentTelConfig, error) { - cfg := DefaultConfig() - data, err := os.ReadFile(path) if err != nil { - return cfg, err + return DefaultConfig(), err + } + cfg, err := ParseConfig(data) + if err != nil { + return cfg, fmt.Errorf("agenttel: %s: %w", path, err) } + applyEnvOverrides(&cfg) + return cfg, nil +} - if err := yaml.Unmarshal(data, &cfg); err != nil { +// LoadConfigFile is an alias of LoadConfig. +func LoadConfigFile(path string) (AgentTelConfig, error) { return LoadConfig(path) } + +// ParseConfig parses YAML in the shared AgentTel format (or a legacy dialect). +// Environment overrides are not applied. +func ParseConfig(data []byte) (AgentTelConfig, error) { + cfg := DefaultConfig() + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { return cfg, err } - - applyEnvOverrides(&cfg) + normalized, err := yaml.Marshal(normalizeRaw(raw)) + if err != nil { + return cfg, err + } + if err := yaml.Unmarshal(normalized, &cfg); err != nil { + return cfg, err + } + for name, op := range cfg.Operations { + for _, v := range []string{op.ExpectedLatencyP50, op.ExpectedLatencyP99} { + if _, _, err := operations.ParseDurationMs(v); err != nil { + return cfg, fmt.Errorf("operation %q: %w", name, err) + } + } + } return cfg, nil } @@ -125,11 +197,11 @@ func LoadConfigFromEnv() (AgentTelConfig, error) { func applyEnvOverrides(cfg *AgentTelConfig) { envMap := map[string]*string{ - "AGENTTEL_TOPOLOGY_TEAM": &cfg.Topology.Team, - "AGENTTEL_TOPOLOGY_TIER": &cfg.Topology.Tier, - "AGENTTEL_TOPOLOGY_DOMAIN": &cfg.Topology.Domain, + "AGENTTEL_TOPOLOGY_TEAM": &cfg.Topology.Team, + "AGENTTEL_TOPOLOGY_TIER": &cfg.Topology.Tier, + "AGENTTEL_TOPOLOGY_DOMAIN": &cfg.Topology.Domain, "AGENTTEL_TOPOLOGY_ON_CALL_CHANNEL": &cfg.Topology.OnCallChannel, - "AGENTTEL_TOPOLOGY_REPO_URL": &cfg.Topology.RepoURL, + "AGENTTEL_TOPOLOGY_REPO_URL": &cfg.Topology.RepoURL, } for envKey, field := range envMap { @@ -142,3 +214,128 @@ func applyEnvOverrides(cfg *AgentTelConfig) { cfg.Enabled = strings.EqualFold(val, "true") } } + +// --- raw document normalization (shared dialect + backward compatibility) --- + +// warnf reports deprecated config usage. Replaceable for tests. +var warnf = log.Printf + +// Sections whose keys are user-chosen names, not schema keys. +var namedMaps = map[string]bool{"operations": true, "profiles": true, "slo": true, "agent-roles": true} + +var camelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`) + +func toKebab(key string) string { + return strings.ToLower(strings.ReplaceAll(camelBoundary.ReplaceAllString(key, "$1-$2"), "_", "-")) +} + +func normalizeRaw(raw map[string]any) map[string]any { + if root, ok := asMap(raw["agenttel"]); ok { + raw = root + } else if len(raw) > 0 { + warnf("agenttel: config without a top-level 'agenttel:' key is deprecated; nest the configuration under 'agenttel:'") + } + + legacy := map[string]bool{} + out, _ := kebabKeys(raw, "", legacy).(map[string]any) + if out == nil { + out = map[string]any{} + } + + if list, ok := out["operations"].([]any); ok { + warnf("agenttel: 'operations' as a list is deprecated; use a map keyed by \"METHOD /route\"") + converted := map[string]any{} + for _, item := range list { + m, ok := asMap(item) + if !ok || m["name"] == nil { + continue + } + name := fmt.Sprint(m["name"]) + delete(m, "name") + converted[operations.Normalize(name)] = m + } + out["operations"] = converted + } + if ops, ok := asMap(out["operations"]); ok { + for _, v := range ops { + op, ok := asMap(v) + if !ok { + continue + } + for old, canonical := range map[string]string{ + "baseline-latency-p50-ms": "expected-latency-p50", + "baseline-latency-p99-ms": "expected-latency-p99", + "baseline-error-rate": "expected-error-rate", + } { + if val, ok := op[old]; ok { + legacy[old] = true + if _, exists := op[canonical]; !exists { + op[canonical] = val + } + delete(op, old) + } + } + // Latencies may be numbers (milliseconds); the struct field is a string. + for _, k := range []string{"expected-latency-p50", "expected-latency-p99"} { + if val, ok := op[k]; ok && val != nil { + op[k] = fmt.Sprint(val) + } + } + } + } + + if len(legacy) > 0 { + keys := make([]string, 0, len(legacy)) + for k := range legacy { + keys = append(keys, k) + } + sort.Strings(keys) + warnf("agenttel: config uses deprecated key spellings %v; use kebab-case (e.g. 'on-call-channel', 'expected-latency-p50')", keys) + } + return out +} + +func kebabKeys(value any, section string, legacy map[string]bool) any { + switch v := value.(type) { + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = kebabKeys(item, "", legacy) + } + return out + case map[string]any, map[any]any: + m, _ := asMap(v) + out := make(map[string]any, len(m)) + for key, child := range m { + if namedMaps[section] { + if section == "operations" { + key = operations.Normalize(key) + } + out[key] = kebabKeys(child, "\x00entry", legacy) + continue + } + k := toKebab(key) + if k != key { + legacy[key] = true + } + out[k] = kebabKeys(child, k, legacy) + } + return out + default: + return value + } +} + +func asMap(v any) (map[string]any, bool) { + switch m := v.(type) { + case map[string]any: + return m, true + case map[any]any: + out := make(map[string]any, len(m)) + for k, val := range m { + out[fmt.Sprint(k)] = val + } + return out, true + } + return nil, false +} diff --git a/agenttel-go/config_test.go b/agenttel-go/config_test.go index 62a9889..c946ec2 100644 --- a/agenttel-go/config_test.go +++ b/agenttel-go/config_test.go @@ -85,3 +85,92 @@ func TestEnvOverrides(t *testing.T) { t.Errorf("expected tier=internal, got %s", cfg.Topology.Tier) } } + +func TestSharedConfigFormat(t *testing.T) { + cfg, err := ParseConfig([]byte(` +agenttel: + topology: + team: payments + on-call-channel: "#oncall" + profiles: + critical-write: + retryable: false + runbook-url: https://profile + escalation-level: page_oncall + operations: + "[POST /api/payments]": + profile: critical-write + expected-latency-p50: 45ms + expected-latency-p99: 1.5s + expected-error-rate: 0.001 + retryable: true + GET /orders/:id: + expected-latency-p50: 15 + baselines: + rolling-window-size: 500 + anomaly-detection: + z-score-threshold: 2.5 + slo: + availability: + target: 0.999 + type: availability +`)) + if err != nil { + t.Fatal(err) + } + if cfg.Topology.OnCallChannel != "#oncall" || cfg.Baselines.RollingWindowSize != 500 || cfg.AnomalyDetection.ZScoreThreshold != 2.5 { + t.Errorf("unexpected config: %+v", cfg) + } + if _, ok := cfg.Operations["POST /api/payments"]; !ok { + t.Fatalf("bracketed key not normalized: %v", cfg.Operations) + } + if cfg.Operations["GET /orders/{id}"].ExpectedLatencyP50 != "15" { + t.Errorf("numeric latency not accepted: %+v", cfg.Operations) + } + + engine, err := New().WithConfig(cfg).Build() + if err != nil { + t.Fatal(err) + } + b, ok := engine.BaselineProvider.GetBaseline("POST /api/payments") + if !ok || b.LatencyP50Ms != 45 || b.LatencyP99Ms != 1500 || b.ErrorRate != 0.001 { + t.Errorf("static baseline from config = %+v, %v", b, ok) + } + if b, ok := engine.BaselineProvider.GetBaseline("GET /orders/{id}"); !ok || b.LatencyP50Ms != 15 { + t.Errorf("GET baseline = %+v, %v", b, ok) + } + d, ok := engine.Operations.GetDecision("POST /api/payments") + if !ok || !d.Retryable || d.RunbookURL != "https://profile" || d.EscalationLevel != "page_oncall" || !d.SafeToRestart { + t.Errorf("decision = %+v, %v", d, ok) + } + if d, _ := engine.Operations.GetDecision("GET /orders/{id}"); d.EscalationLevel != "auto_resolve" { + t.Errorf("default escalation = %q", d.EscalationLevel) + } +} + +func TestLegacyNodeOperationsList(t *testing.T) { + cfg, err := ParseConfig([]byte(` +topology: + on_call_channel: "#c" +operations: + - name: POST /p + baseline_latency_p50_ms: 45 + baseline_latency_p99_ms: 200 +`)) + if err != nil { + t.Fatal(err) + } + if cfg.Topology.OnCallChannel != "#c" { + t.Errorf("on_call_channel = %q", cfg.Topology.OnCallChannel) + } + op := cfg.Operations["POST /p"] + if op.ExpectedLatencyP50 != "45" || op.ExpectedLatencyP99 != "200" { + t.Errorf("legacy operation = %+v", op) + } +} + +func TestInvalidLatencyIsAnError(t *testing.T) { + if _, err := ParseConfig([]byte("agenttel:\n operations:\n GET /x:\n expected-latency-p50: fast\n")); err == nil { + t.Error("expected error for invalid latency") + } +} diff --git a/agenttel-go/enrich/enrich.go b/agenttel-go/enrich/enrich.go new file mode 100644 index 0000000..f41ceaf --- /dev/null +++ b/agenttel-go/enrich/enrich.go @@ -0,0 +1,67 @@ +// Package enrich sets AgentTel attributes on OTel spans. It is shared by the +// span processor and the framework middleware. +package enrich + +import ( + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + attrs "go.agenttel.dev/agenttel-go/attributes" + "go.agenttel.dev/agenttel-go/baseline" + "go.agenttel.dev/agenttel-go/operations" + "go.agenttel.dev/agenttel-go/topology" +) + +// Topology sets team, tier, domain and on-call channel on the span. +func Topology(span trace.Span, t *topology.Registry) { + if t == nil { + return + } + for k, v := range map[string]string{ + attrs.TopologyTeam: t.Team(), + attrs.TopologyTier: t.Tier(), + attrs.TopologyDomain: t.Domain(), + attrs.TopologyOnCallChannel: t.OnCallChannel(), + } { + if v != "" { + span.SetAttributes(attribute.String(k, v)) + } + } +} + +// Operation sets baseline and decision attributes for operationName +// ("METHOD /route") and reports whether anything was found. +func Operation(span trace.Span, operationName string, baselines baseline.Provider, decisions operations.Provider) bool { + name := operations.Normalize(operationName) + found := false + if baselines != nil { + if b, ok := baselines.GetBaseline(name); ok { + span.SetAttributes( + attribute.Float64(attrs.BaselineLatencyP50Ms, b.LatencyP50Ms), + attribute.Float64(attrs.BaselineLatencyP99Ms, b.LatencyP99Ms), + attribute.Float64(attrs.BaselineErrorRate, b.ErrorRate), + attribute.String(attrs.BaselineSource, b.Source), + ) + found = true + } + } + if decisions != nil { + if d, ok := decisions.GetDecision(name); ok { + span.SetAttributes( + attribute.Bool(attrs.DecisionRetryable, d.Retryable), + attribute.Bool(attrs.DecisionIdempotent, d.Idempotent), + attribute.Bool(attrs.DecisionSafeToRestart, d.SafeToRestart), + attribute.String(attrs.DecisionEscalationLevel, d.EscalationLevel), + attribute.Bool(attrs.DecisionFallbackAvailable, d.FallbackDescription != ""), + ) + if d.RunbookURL != "" { + span.SetAttributes(attribute.String(attrs.DecisionRunbookURL, d.RunbookURL)) + } + if d.FallbackDescription != "" { + span.SetAttributes(attribute.String(attrs.DecisionFallbackDescription, d.FallbackDescription)) + } + found = true + } + } + return found +} diff --git a/agenttel-go/go.mod b/agenttel-go/go.mod index 7540387..bfcdaa7 100644 --- a/agenttel-go/go.mod +++ b/agenttel-go/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.13 require ( + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 @@ -14,6 +15,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/agenttel-go/go.sum b/agenttel-go/go.sum index 9f7dcf7..94b838f 100644 --- a/agenttel-go/go.sum +++ b/agenttel-go/go.sum @@ -2,6 +2,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -25,6 +27,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= diff --git a/agenttel-go/middleware/gin/middleware.go b/agenttel-go/middleware/gin/middleware.go index 7a11e45..910987a 100644 --- a/agenttel-go/middleware/gin/middleware.go +++ b/agenttel-go/middleware/gin/middleware.go @@ -1,17 +1,30 @@ -// Package gin provides Gin framework middleware for AgentTel span enrichment. +// Package gin provides Gin framework helpers for AgentTel span enrichment. +// +// To avoid a hard dependency on Gin, this package exposes EnrichSpan; register +// it from a one-line Gin middleware after otelgin: +// +// cfg := aggin.NewConfig(aggin.WithEngine(engine)) +// r.Use(otelgin.Middleware("payment-service")) +// r.Use(func(c *gin.Context) { +// aggin.EnrichSpan(trace.SpanFromContext(c.Request.Context()), c.Request.Method, c.FullPath(), cfg) +// c.Next() +// }) package gin import ( - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - attrs "go.agenttel.dev/agenttel-go/attributes" + agenttel "go.agenttel.dev/agenttel-go" "go.agenttel.dev/agenttel-go/baseline" + "go.agenttel.dev/agenttel-go/enrich" + "go.agenttel.dev/agenttel-go/operations" "go.agenttel.dev/agenttel-go/topology" ) // GinContext is a minimal interface for the gin.Context methods we need, // to avoid a hard dependency on the gin package. +// +// Deprecated: unused; call EnrichSpan from a gin.HandlerFunc instead. type GinContext interface { Request() interface{ Method() string } FullPath() string @@ -22,11 +35,30 @@ type GinContext interface { type Config struct { BaselineProvider baseline.Provider Topology *topology.Registry + Decisions operations.Provider } // Option configures the middleware. type Option func(*Config) +// NewConfig builds a Config from options. +func NewConfig(opts ...Option) *Config { + cfg := &Config{} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// WithEngine takes topology, baselines and decisions from an AgentTel engine. +func WithEngine(e *agenttel.Engine) Option { + return func(c *Config) { + c.BaselineProvider = e.BaselineProvider + c.Topology = e.TopologyRegistry + c.Decisions = e.Operations + } +} + // WithBaselineProvider sets the baseline provider. func WithBaselineProvider(p baseline.Provider) Option { return func(c *Config) { c.BaselineProvider = p } @@ -37,36 +69,20 @@ func WithTopology(t *topology.Registry) Option { return func(c *Config) { c.Topology = t } } -// EnrichSpan enriches an OTel span with AgentTel topology and baseline attributes. -// This is a helper that can be called from any Gin middleware. +// WithDecisions sets the per-operation decision context (retryable, runbook, ...). +func WithDecisions(p operations.Provider) Option { + return func(c *Config) { c.Decisions = p } +} + +// EnrichSpan enriches an OTel span with AgentTel topology, baseline and +// decision attributes for "METHOD routePattern" (Gin's c.FullPath(), e.g. +// "/api/payments/:id", which matches a config key "GET /api/payments/{id}"). func EnrichSpan(span trace.Span, method, routePattern string, cfg *Config) { - if cfg == nil { + if cfg == nil || !span.IsRecording() { return } - - // Topology enrichment - if cfg.Topology != nil { - if team := cfg.Topology.Team(); team != "" { - span.SetAttributes(attribute.String(attrs.TopologyTeam, team)) - } - if tier := cfg.Topology.Tier(); tier != "" { - span.SetAttributes(attribute.String(attrs.TopologyTier, tier)) - } - if domain := cfg.Topology.Domain(); domain != "" { - span.SetAttributes(attribute.String(attrs.TopologyDomain, domain)) - } - } - - // Baseline enrichment - opName := method + " " + routePattern - if cfg.BaselineProvider != nil && opName != "" { - if b, ok := cfg.BaselineProvider.GetBaseline(opName); ok { - span.SetAttributes( - attribute.Float64(attrs.BaselineLatencyP50Ms, b.LatencyP50Ms), - attribute.Float64(attrs.BaselineLatencyP99Ms, b.LatencyP99Ms), - attribute.Float64(attrs.BaselineErrorRate, b.ErrorRate), - attribute.String(attrs.BaselineSource, b.Source), - ) - } + enrich.Topology(span, cfg.Topology) + if routePattern != "" { + enrich.Operation(span, method+" "+routePattern, cfg.BaselineProvider, cfg.Decisions) } } diff --git a/agenttel-go/middleware/grpc/interceptor.go b/agenttel-go/middleware/grpc/interceptor.go index 81cf140..a2311a8 100644 --- a/agenttel-go/middleware/grpc/interceptor.go +++ b/agenttel-go/middleware/grpc/interceptor.go @@ -4,19 +4,22 @@ package grpc import ( "context" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" - attrs "go.agenttel.dev/agenttel-go/attributes" + agenttel "go.agenttel.dev/agenttel-go" "go.agenttel.dev/agenttel-go/baseline" + "go.agenttel.dev/agenttel-go/enrich" + "go.agenttel.dev/agenttel-go/operations" "go.agenttel.dev/agenttel-go/topology" ) -// Config holds interceptor configuration. +// Config holds interceptor configuration. Operations are keyed by the gRPC +// full method name, e.g. "/payments.v1.Payments/Charge". type Config struct { BaselineProvider baseline.Provider Topology *topology.Registry + Decisions operations.Provider } // Option configures the interceptor. @@ -32,6 +35,20 @@ func WithTopology(t *topology.Registry) Option { return func(c *Config) { c.Topology = t } } +// WithDecisions sets the per-operation decision context (retryable, runbook, ...). +func WithDecisions(p operations.Provider) Option { + return func(c *Config) { c.Decisions = p } +} + +// WithEngine takes topology, baselines and decisions from an AgentTel engine. +func WithEngine(e *agenttel.Engine) Option { + return func(c *Config) { + c.BaselineProvider = e.BaselineProvider + c.Topology = e.TopologyRegistry + c.Decisions = e.Operations + } +} + // UnaryServerInterceptor returns a gRPC unary server interceptor that // enriches the current span with AgentTel attributes. func UnaryServerInterceptor(opts ...Option) grpc.UnaryServerInterceptor { @@ -75,28 +92,9 @@ func buildConfig(opts []Option) *Config { } func enrichSpan(span trace.Span, fullMethod string, cfg *Config) { - // Topology - if cfg.Topology != nil { - if team := cfg.Topology.Team(); team != "" { - span.SetAttributes(attribute.String(attrs.TopologyTeam, team)) - } - if tier := cfg.Topology.Tier(); tier != "" { - span.SetAttributes(attribute.String(attrs.TopologyTier, tier)) - } - if domain := cfg.Topology.Domain(); domain != "" { - span.SetAttributes(attribute.String(attrs.TopologyDomain, domain)) - } - } - - // Baselines - if cfg.BaselineProvider != nil { - if b, ok := cfg.BaselineProvider.GetBaseline(fullMethod); ok { - span.SetAttributes( - attribute.Float64(attrs.BaselineLatencyP50Ms, b.LatencyP50Ms), - attribute.Float64(attrs.BaselineLatencyP99Ms, b.LatencyP99Ms), - attribute.Float64(attrs.BaselineErrorRate, b.ErrorRate), - attribute.String(attrs.BaselineSource, b.Source), - ) - } + if !span.IsRecording() { + return } + enrich.Topology(span, cfg.Topology) + enrich.Operation(span, fullMethod, cfg.BaselineProvider, cfg.Decisions) } diff --git a/agenttel-go/middleware/http/integration_test.go b/agenttel-go/middleware/http/integration_test.go new file mode 100644 index 0000000..e5ee8dd --- /dev/null +++ b/agenttel-go/middleware/http/integration_test.go @@ -0,0 +1,138 @@ +package http_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + agenttel "go.agenttel.dev/agenttel-go" + agmw "go.agenttel.dev/agenttel-go/middleware/http" +) + +const config = ` +agenttel: + topology: + service-name: payment-service + team: payments-platform + tier: critical + domain: commerce + on-call-channel: "#payments-oncall" + profiles: + critical-write: + escalation-level: page_oncall + operations: + "[POST /api/payments]": + profile: critical-write + expected-latency-p50: 45ms + expected-latency-p99: 200ms + expected-error-rate: 0.001 + retryable: true + idempotent: true + runbook-url: https://wiki.example.com/runbooks/payments + "GET /api/payments/:id": + expected-latency-p50: 15 + expected-latency-p99: 80 +` + +// End-to-end, mirroring the Spring Boot EnrichmentTest: a real HTTP server with +// otelhttp + AgentTel middleware, one request, then assert the server span. +func TestServerSpanIsEnriched(t *testing.T) { + path := filepath.Join(t.TempDir(), "agenttel.yml") + if err := os.WriteFile(path, []byte(config), 0o644); err != nil { + t.Fatal(err) + } + engine, err := agenttel.New().WithConfigFile(path).Build() + if err != nil { + t.Fatal(err) + } + + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider( + sdktrace.WithResource(engine.Resource()), + sdktrace.WithSpanProcessor(engine.SpanProcessor()), + sdktrace.WithSyncer(exporter), + ) + defer tp.Shutdown(context.Background()) + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/payments", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(`{"ok":true}`)) }) + mux.HandleFunc("GET /api/payments/{id}", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.PathValue("id"))) }) + handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server", otelhttp.WithTracerProvider(tp)) + + srv := httptest.NewServer(handler) + for _, req := range []struct{ method, path string }{{"POST", "/api/payments"}, {"GET", "/api/payments/pay_123"}} { + r, _ := http.NewRequest(req.method, srv.URL+req.path, strings.NewReader("{}")) + resp, err := http.DefaultClient.Do(r) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + } + srv.Close() + + byRoute := map[string]map[attribute.Key]attribute.Value{} + var resourceAttrs []attribute.KeyValue + for _, s := range exporter.GetSpans() { + if s.SpanKind != trace.SpanKindServer { + continue + } + attrs := map[attribute.Key]attribute.Value{} + for _, kv := range s.Attributes { + attrs[kv.Key] = kv.Value + } + byRoute[attrs["http.route"].AsString()] = attrs + resourceAttrs = s.Resource.Attributes() + } + + post, ok := byRoute["/api/payments"] + if !ok { + t.Fatalf("no server span for /api/payments: %v", byRoute) + } + expect := map[string]any{ + "agenttel.topology.team": "payments-platform", + "agenttel.topology.on_call_channel": "#payments-oncall", + "agenttel.baseline.latency_p50_ms": 45.0, + "agenttel.baseline.latency_p99_ms": 200.0, + "agenttel.baseline.error_rate": 0.001, + "agenttel.decision.retryable": true, + "agenttel.decision.idempotent": true, + "agenttel.decision.runbook_url": "https://wiki.example.com/runbooks/payments", + "agenttel.decision.escalation_level": "page_oncall", + } + for k, want := range expect { + if got := post[attribute.Key(k)].AsInterface(); got != want { + t.Errorf("%s = %v, want %v", k, got, want) + } + } + + get, ok := byRoute["/api/payments/{id}"] + if !ok { + t.Fatalf("no server span for /api/payments/{id}: %v", byRoute) + } + if got := get["agenttel.baseline.latency_p50_ms"].AsFloat64(); got != 15 { + t.Errorf("GET p50 = %v, want 15", got) + } + if got := get["agenttel.decision.retryable"].AsBool(); got { + t.Errorf("GET retryable = true, want false") + } + + foundTeam := false + for _, kv := range resourceAttrs { + if kv.Key == "agenttel.topology.team" && kv.Value.AsString() == "payments-platform" { + foundTeam = true + } + } + if !foundTeam { + t.Errorf("resource lacks agenttel.topology.team: %v", resourceAttrs) + } +} diff --git a/agenttel-go/middleware/http/middleware.go b/agenttel-go/middleware/http/middleware.go index 686669d..7867526 100644 --- a/agenttel-go/middleware/http/middleware.go +++ b/agenttel-go/middleware/http/middleware.go @@ -1,16 +1,22 @@ // Package http provides net/http middleware for AgentTel span enrichment. +// +// The middleware enriches the span that is already active on the request +// context, so wrap it in an OTel HTTP instrumentation such as otelhttp: +// +// handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server") package http import ( "net/http" - "time" + "strings" - "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - attrs "go.agenttel.dev/agenttel-go/attributes" + agenttel "go.agenttel.dev/agenttel-go" "go.agenttel.dev/agenttel-go/baseline" + "go.agenttel.dev/agenttel-go/enrich" + "go.agenttel.dev/agenttel-go/operations" "go.agenttel.dev/agenttel-go/topology" ) @@ -18,12 +24,22 @@ import ( type Config struct { BaselineProvider baseline.Provider Topology *topology.Registry + Decisions operations.Provider RouteResolver func(r *http.Request) string } // Option configures the middleware. type Option func(*Config) +// WithEngine takes topology, baselines and decisions from an AgentTel engine. +func WithEngine(e *agenttel.Engine) Option { + return func(c *Config) { + c.BaselineProvider = e.BaselineProvider + c.Topology = e.TopologyRegistry + c.Decisions = e.Operations + } +} + // WithBaselineProvider sets the baseline provider for enrichment. func WithBaselineProvider(p baseline.Provider) Option { return func(c *Config) { c.BaselineProvider = p } @@ -34,12 +50,23 @@ func WithTopology(t *topology.Registry) Option { return func(c *Config) { c.Topology = t } } -// WithRouteResolver sets a custom route pattern resolver. +// WithDecisions sets the per-operation decision context (retryable, runbook, ...). +func WithDecisions(p operations.Provider) Option { + return func(c *Config) { c.Decisions = p } +} + +// WithRouteResolver sets a custom resolver returning the operation name +// ("METHOD /route") for a request. func WithRouteResolver(fn func(r *http.Request) string) Option { return func(c *Config) { c.RouteResolver = fn } } -// Middleware returns an http.Handler that enriches the current OTel span with AgentTel attributes. +// Middleware returns an http.Handler wrapper that enriches the active OTel span +// with AgentTel topology, baseline and decision attributes for "METHOD /route". +// +// Without a RouteResolver the operation is resolved from the request path and, +// when the wrapped handler is a Go 1.22+ http.ServeMux, again from the matched +// pattern (e.g. "GET /api/payments/{id}") after routing. func Middleware(opts ...Option) func(http.Handler) http.Handler { cfg := &Config{} for _, opt := range opts { @@ -48,28 +75,25 @@ func Middleware(opts ...Option) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() span := trace.SpanFromContext(r.Context()) - - // Resolve operation name - opName := resolveOperationName(r, cfg) - - // Enrich with topology - if cfg.Topology != nil { - enrichTopology(span, cfg.Topology) + if !span.IsRecording() { + next.ServeHTTP(w, r) + return } - // Enrich with baselines - if cfg.BaselineProvider != nil && opName != "" { - enrichBaseline(span, cfg.BaselineProvider, opName) - } + enrich.Topology(span, cfg.Topology) + first := resolveOperationName(r, cfg) + found := enrich.Operation(span, first, cfg.BaselineProvider, cfg.Decisions) - // Wrap response writer to capture status - rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} - next.ServeHTTP(rw, r) + next.ServeHTTP(w, r) - _ = time.Since(start) - _ = otel.Tracer("agenttel") + if cfg.RouteResolver == nil && r.Pattern != "" { + route, op := patternOperation(r) + span.SetAttributes(attribute.String("http.route", route)) + if !found && op != first { + enrich.Operation(span, op, cfg.BaselineProvider, cfg.Decisions) + } + } }) } } @@ -78,40 +102,22 @@ func resolveOperationName(r *http.Request, cfg *Config) string { if cfg.RouteResolver != nil { return cfg.RouteResolver(r) } + if r.Pattern != "" { + _, op := patternOperation(r) + return op + } return r.Method + " " + r.URL.Path } -func enrichTopology(span trace.Span, t *topology.Registry) { - if team := t.Team(); team != "" { - span.SetAttributes(attribute.String(attrs.TopologyTeam, team)) - } - if tier := t.Tier(); tier != "" { - span.SetAttributes(attribute.String(attrs.TopologyTier, tier)) - } - if domain := t.Domain(); domain != "" { - span.SetAttributes(attribute.String(attrs.TopologyDomain, domain)) +// patternOperation converts a ServeMux pattern ("[METHOD ][HOST]/path") to +// the route and "METHOD /path" operation name. +func patternOperation(r *http.Request) (route, op string) { + method, path := r.Method, r.Pattern + if m, rest, ok := strings.Cut(r.Pattern, " "); ok { + method, path = m, strings.TrimSpace(rest) } -} - -func enrichBaseline(span trace.Span, provider baseline.Provider, opName string) { - b, ok := provider.GetBaseline(opName) - if !ok { - return + if i := strings.Index(path, "/"); i > 0 { + path = path[i:] // drop host } - span.SetAttributes( - attribute.Float64(attrs.BaselineLatencyP50Ms, b.LatencyP50Ms), - attribute.Float64(attrs.BaselineLatencyP99Ms, b.LatencyP99Ms), - attribute.Float64(attrs.BaselineErrorRate, b.ErrorRate), - attribute.String(attrs.BaselineSource, b.Source), - ) -} - -type responseWriter struct { - http.ResponseWriter - statusCode int -} - -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) + return path, method + " " + path } diff --git a/agenttel-go/operations/operations.go b/agenttel-go/operations/operations.go new file mode 100644 index 0000000..a747e22 --- /dev/null +++ b/agenttel-go/operations/operations.go @@ -0,0 +1,95 @@ +// Package operations holds per-operation decision context and the helpers that +// give every AgentTel SDK the same operation keys ("METHOD /route") and +// duration format ("45ms", or a number of milliseconds). +package operations + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "sync" +) + +// Decision is the context an agent needs to act safely on an operation. +type Decision struct { + Retryable bool + Idempotent bool + RunbookURL string + FallbackDescription string + EscalationLevel string + SafeToRestart bool +} + +// Provider looks up decision context by operation name. +type Provider interface { + GetDecision(operationName string) (Decision, bool) +} + +// Registry is a concurrency-safe Provider keyed by normalized operation name. +type Registry struct { + mu sync.RWMutex + decisions map[string]Decision +} + +// NewRegistry creates an empty decision registry. +func NewRegistry() *Registry { + return &Registry{decisions: make(map[string]Decision)} +} + +// Register adds or replaces the decision context for an operation. +func (r *Registry) Register(operationName string, d Decision) { + r.mu.Lock() + defer r.mu.Unlock() + r.decisions[Normalize(operationName)] = d +} + +// GetDecision returns the decision context for an operation, if configured. +func (r *Registry) GetDecision(operationName string) (Decision, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + d, ok := r.decisions[Normalize(operationName)] + return d, ok +} + +var pathParam = regexp.MustCompile(`(^|/):([A-Za-z_][A-Za-z0-9_]*)`) + +// Normalize returns the canonical "METHOD /route" form of an operation key: +// Spring-style [brackets] are stripped, the HTTP method is upper-cased and +// ":param" path segments become "{param}", so net/http, Gin, Express and +// FastAPI route templates all share one key. Other names are only trimmed. +func Normalize(name string) string { + n := strings.TrimSpace(name) + if strings.HasPrefix(n, "[") && strings.HasSuffix(n, "]") { + n = strings.TrimSpace(n[1 : len(n)-1]) + } + method, route, ok := strings.Cut(n, " ") + route = strings.TrimSpace(route) + if ok && strings.HasPrefix(route, "/") { + n = strings.ToUpper(method) + " " + pathParam.ReplaceAllString(route, "$1{$2}") + } + return n +} + +// ParseDurationMs parses "45ms", "1.5s", "2m" or a plain number of +// milliseconds ("45"). An empty string returns (0, false, nil). +func ParseDurationMs(value string) (ms float64, ok bool, err error) { + v := strings.ToLower(strings.TrimSpace(value)) + if v == "" { + return 0, false, nil + } + mult := 1.0 + switch { + case strings.HasSuffix(v, "ms"): + v = strings.TrimSuffix(v, "ms") + case strings.HasSuffix(v, "s"): + v, mult = strings.TrimSuffix(v, "s"), 1000 + case strings.HasSuffix(v, "m"): + v, mult = strings.TrimSuffix(v, "m"), 60000 + } + f, perr := strconv.ParseFloat(strings.TrimSpace(v), 64) + if perr != nil || f < 0 { + return 0, false, fmt.Errorf("agenttel: invalid duration %q (expected e.g. \"45ms\" or 45)", value) + } + return f * mult, true, nil +} diff --git a/agenttel-go/operations/operations_test.go b/agenttel-go/operations/operations_test.go new file mode 100644 index 0000000..4c33ae5 --- /dev/null +++ b/agenttel-go/operations/operations_test.go @@ -0,0 +1,35 @@ +package operations + +import "testing" + +func TestNormalize(t *testing.T) { + cases := map[string]string{ + "[POST /api/payments]": "POST /api/payments", + "post /api/payments": "POST /api/payments", + "GET /orders/:id": "GET /orders/{id}", + "GET /orders/{id}": "GET /orders/{id}", + "process-payment": "process-payment", + "/pkg.Svc/Method": "/pkg.Svc/Method", + } + for in, want := range cases { + if got := Normalize(in); got != want { + t.Errorf("Normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseDurationMs(t *testing.T) { + cases := map[string]float64{"45ms": 45, "1.5s": 1500, "2m": 120000, "45": 45, " 10 ms ": 10} + for in, want := range cases { + got, ok, err := ParseDurationMs(in) + if err != nil || !ok || got != want { + t.Errorf("ParseDurationMs(%q) = %v, %v, %v; want %v", in, got, ok, err, want) + } + } + if _, ok, err := ParseDurationMs(""); ok || err != nil { + t.Errorf("empty duration should be (0, false, nil)") + } + if _, _, err := ParseDurationMs("fast"); err == nil { + t.Errorf("expected error for invalid duration") + } +} diff --git a/agenttel-go/processor/processor.go b/agenttel-go/processor/processor.go index 33fe8f2..386f9a3 100644 --- a/agenttel-go/processor/processor.go +++ b/agenttel-go/processor/processor.go @@ -12,8 +12,10 @@ import ( "go.agenttel.dev/agenttel-go/anomaly" "go.agenttel.dev/agenttel-go/attributes" "go.agenttel.dev/agenttel-go/baseline" + "go.agenttel.dev/agenttel-go/enrich" "go.agenttel.dev/agenttel-go/errorclass" "go.agenttel.dev/agenttel-go/events" + "go.agenttel.dev/agenttel-go/operations" "go.agenttel.dev/agenttel-go/slo" "go.agenttel.dev/agenttel-go/topology" ) @@ -31,6 +33,7 @@ type AgentTelSpanProcessor struct { errorClassifier *errorclass.Classifier topologyRegistry *topology.Registry eventEmitter *events.Emitter + decisions operations.Provider listener SpanCompletionListener } @@ -77,6 +80,11 @@ func WithEventEmitter(e *events.Emitter) Option { return func(sp *AgentTelSpanProcessor) { sp.eventEmitter = e } } +// WithDecisions sets the per-operation decision context provider. +func WithDecisions(d operations.Provider) Option { + return func(sp *AgentTelSpanProcessor) { sp.decisions = d } +} + // WithCompletionListener sets a listener called after each span is processed. func WithCompletionListener(l SpanCompletionListener) Option { return func(sp *AgentTelSpanProcessor) { sp.listener = l } @@ -91,40 +99,23 @@ func New(opts ...Option) *AgentTelSpanProcessor { return p } -// OnStart enriches spans with baseline and topology attributes at creation time. +// OnStart enriches spans with topology, and with baseline and decision +// attributes when the span name is a configured operation. HTTP server spans +// are usually named before routing; the framework middleware covers those. func (p *AgentTelSpanProcessor) OnStart(_ context.Context, span sdktrace.ReadWriteSpan) { - name := span.Name() - - // Enrich with topology - if p.topologyRegistry != nil { - if team := p.topologyRegistry.Team(); team != "" { - span.SetAttributes(attribute.String(attributes.TopologyTeam, team)) - } - if tier := p.topologyRegistry.Tier(); tier != "" { - span.SetAttributes(attribute.String(attributes.TopologyTier, tier)) - } - if domain := p.topologyRegistry.Domain(); domain != "" { - span.SetAttributes(attribute.String(attributes.TopologyDomain, domain)) - } - } - - // Enrich with baselines - if p.baselineProvider != nil { - if b, ok := p.baselineProvider.GetBaseline(name); ok { - span.SetAttributes( - attribute.Float64(attributes.BaselineLatencyP50Ms, b.LatencyP50Ms), - attribute.Float64(attributes.BaselineLatencyP99Ms, b.LatencyP99Ms), - attribute.Float64(attributes.BaselineErrorRate, b.ErrorRate), - attribute.String(attributes.BaselineSource, b.Source), - attribute.String(attributes.BaselineUpdatedAt, b.UpdatedAt.Format(time.RFC3339)), - ) + enrich.Topology(span, p.topologyRegistry) + if enrich.Operation(span, span.Name(), p.baselineProvider, p.decisions) { + if p.baselineProvider != nil { + if b, ok := p.baselineProvider.GetBaseline(operations.Normalize(span.Name())); ok { + span.SetAttributes(attribute.String(attributes.BaselineUpdatedAt, b.UpdatedAt.Format(time.RFC3339))) + } } } } // OnEnd records rolling baselines, performs anomaly detection, and tracks SLOs. func (p *AgentTelSpanProcessor) OnEnd(span sdktrace.ReadOnlySpan) { - name := span.Name() + name := operationName(span) durationMs := float64(span.EndTime().Sub(span.StartTime()).Milliseconds()) isError := span.Status().Code == codes.Error @@ -176,3 +167,21 @@ func (p *AgentTelSpanProcessor) Shutdown(_ context.Context) error { func (p *AgentTelSpanProcessor) ForceFlush(_ context.Context) error { return nil } + +// operationName is "METHOD /route" for HTTP server spans with a route, +// otherwise the span name. +func operationName(span sdktrace.ReadOnlySpan) string { + var route, method string + for _, kv := range span.Attributes() { + switch kv.Key { + case "http.route": + route = kv.Value.AsString() + case "http.request.method", "http.method": + method = kv.Value.AsString() + } + } + if route != "" && method != "" { + return operations.Normalize(method + " " + route) + } + return operations.Normalize(span.Name()) +} diff --git a/examples/gin-example/agenttel.yml b/examples/gin-example/agenttel.yml index 9213f82..037e78f 100644 --- a/examples/gin-example/agenttel.yml +++ b/examples/gin-example/agenttel.yml @@ -8,12 +8,12 @@ agenttel: dependencies: - name: payment-gateway - type: rest_api - criticality: critical + type: external_api + criticality: required timeout-ms: 5000 - name: customer-db type: database - criticality: critical + criticality: required timeout-ms: 3000 consumers: diff --git a/examples/gin-example/go.mod b/examples/gin-example/go.mod index ac92e6f..db39885 100644 --- a/examples/gin-example/go.mod +++ b/examples/gin-example/go.mod @@ -1,14 +1,61 @@ -module github.com/AgentTel/AgentTel/examples/gin-example +module github.com/AgentTel/agenttel-sdk/examples/gin-example -go 1.24 +go 1.24.0 require ( - go.agenttel.dev/agenttel-go v0.1.0 - github.com/gin-gonic/gin v1.10.0 - go.opentelemetry.io/otel v1.33.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 - go.opentelemetry.io/otel/sdk v1.33.0 - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.58.0 + github.com/gin-gonic/gin v1.11.0 + go.agenttel.dev/agenttel-go v0.4.0-alpha + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace go.agenttel.dev/agenttel-go => ../../agenttel-go diff --git a/examples/gin-example/go.sum b/examples/gin-example/go.sum new file mode 100644 index 0000000..ad81131 --- /dev/null +++ b/examples/gin-example/go.sum @@ -0,0 +1,146 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= +github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0 h1:7IKZbAYwlwLXAdu7SVPhzTjDjogWZxP4MIa7rovY+PU= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0/go.mod h1:+TF5nf3NIv2X8PGxqfYOaRnAoMM43rUA2C3XsN2DoWA= +go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk= +go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/gin-example/main.go b/examples/gin-example/main.go index 12458ee..54fa46b 100644 --- a/examples/gin-example/main.go +++ b/examples/gin-example/main.go @@ -13,11 +13,9 @@ import ( "github.com/gin-gonic/gin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.24.0" "go.opentelemetry.io/otel/trace" agenttel "go.agenttel.dev/agenttel-go" @@ -28,70 +26,48 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - // Load AgentTel config - cfg, err := agenttel.LoadConfig("agenttel.yml") + // Build the AgentTel engine from agenttel.yml: topology, baselines and + // decision context (retryable, runbook, escalation) per "METHOD /route". + engine, err := agenttel.New().WithConfigFile("agenttel.yml").Build() if err != nil { - log.Printf("Warning: could not load agenttel.yml: %v (using defaults)", err) - cfg = agenttel.DefaultConfig() + log.Fatalf("Failed to build AgentTel engine: %v", err) } - // Set up OTel exporter - endpoint := envOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318") - exporter, err := otlptracehttp.New(ctx, - otlptracehttp.WithEndpoint(endpoint), - otlptracehttp.WithInsecure(), - ) - if err != nil { - log.Fatalf("Failed to create OTLP exporter: %v", err) + // Exporter: OTLP/HTTP by default, stdout with OTEL_TRACES_EXPORTER=console. + var exporter sdktrace.SpanExporter + if os.Getenv("OTEL_TRACES_EXPORTER") == "console" { + exporter, err = stdouttrace.New(stdouttrace.WithPrettyPrint()) + } else { + exporter, err = otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(envOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")), + otlptracehttp.WithInsecure(), + ) } - - // Build AgentTel engine - engine, err := agenttel.New().WithConfig(cfg).Build() if err != nil { - log.Fatalf("Failed to build AgentTel engine: %v", err) + log.Fatalf("Failed to create exporter: %v", err) } - // Create TracerProvider with AgentTel processor - res, _ := resource.Merge( - resource.Default(), - resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceName(cfg.Topology.ServiceName), - semconv.ServiceVersion("1.0.0"), - attribute.String("agenttel.topology.team", cfg.Topology.Team), - attribute.String("agenttel.topology.tier", cfg.Topology.Tier), - ), - ) - + // TracerProvider with topology on the resource and the AgentTel processor. tp := sdktrace.NewTracerProvider( - sdktrace.WithBatcher(exporter), + sdktrace.WithResource(engine.Resource()), sdktrace.WithSpanProcessor(engine.SpanProcessor()), - sdktrace.WithResource(res), + sdktrace.WithBatcher(exporter), ) - defer tp.Shutdown(ctx) + defer tp.Shutdown(context.Background()) otel.SetTracerProvider(tp) - // Configure AgentTel enrichment for Gin - enrichCfg := &aggin.Config{} - aggin.WithBaselineProvider(engine.BaselineProvider)(enrichCfg) - aggin.WithTopology(engine.TopologyRegistry)(enrichCfg) - - // Set up Gin router gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) - // Apply OTel instrumentation middleware - r.Use(otelgin.Middleware(cfg.Topology.ServiceName)) + // otelgin creates the server span ... + r.Use(otelgin.Middleware(engine.Config.Topology.ServiceName)) - // Apply AgentTel enrichment via onRequest hook + // ... and AgentTel enriches it. c.FullPath() is the route template + // ("/api/payments/:id"), which matches the config key "GET /api/payments/:id". + enrichCfg := aggin.NewConfig(aggin.WithEngine(engine)) r.Use(func(c *gin.Context) { - span := trace.SpanFromContext(c.Request.Context()) - routePattern := c.FullPath() - if routePattern == "" { - routePattern = c.Request.URL.Path - } - aggin.EnrichSpan(span, c.Request.Method, routePattern, enrichCfg) + aggin.EnrichSpan(trace.SpanFromContext(c.Request.Context()), c.Request.Method, c.FullPath(), enrichCfg) c.Next() }) diff --git a/examples/go-service-example/README.md b/examples/go-service-example/README.md index 4190ae6..c819eef 100644 --- a/examples/go-service-example/README.md +++ b/examples/go-service-example/README.md @@ -1,37 +1,34 @@ # Go Service Example -Demonstrates AgentTel Go SDK with a payment service using `net/http`. +Demonstrates the AgentTel Go SDK with a payment service using `net/http` (Go 1.22+ `ServeMux` patterns). ## Features -- Span enrichment with topology, baselines, anomaly detection -- SLO tracking with error budget -- net/http middleware integration -- YAML configuration +- `agenttel.yml` in the shared AgentTel format (same file shape as Spring Boot, Node.js and Python) +- `otelhttp` creates the HTTP server span; `agmw.Middleware(agmw.WithEngine(engine))` enriches it with + topology, static baselines (`expected-latency-p50` etc.) and decision attributes (`retryable`, `runbook-url`, ...) + for the matched `METHOD /route` +- Topology on the OTel resource via `engine.Resource()` + +Requires `go.opentelemetry.io/otel/sdk` v1.39.0 or newer. ## Run ```bash -# With OTel Collector running on localhost:4318 +# Print spans to stdout +OTEL_TRACES_EXPORTER=console go run . + +# With an OTel Collector on localhost:4318 go run . -# Or with custom endpoint +# Or with a custom endpoint OTEL_EXPORTER_OTLP_ENDPOINT=collector:4318 go run . ``` ## Test ```bash -# Create payment curl -X POST http://localhost:8080/api/payments -H "Content-Type: application/json" - -# Get payment curl http://localhost:8080/api/payments/pay_12345 - -# Health check curl http://localhost:8080/health ``` - -## View Traces - -Open Jaeger at http://localhost:16686 to see enriched traces with AgentTel attributes. diff --git a/examples/go-service-example/agenttel.yml b/examples/go-service-example/agenttel.yml index 6164be1..d907e06 100644 --- a/examples/go-service-example/agenttel.yml +++ b/examples/go-service-example/agenttel.yml @@ -12,7 +12,7 @@ agenttel: criticality: required timeout-ms: 5000 - name: stripe-api - type: rest_api + type: external_api criticality: required fallback: "Return cached pricing" diff --git a/examples/go-service-example/go.mod b/examples/go-service-example/go.mod index fd55f60..1108d71 100644 --- a/examples/go-service-example/go.mod +++ b/examples/go-service-example/go.mod @@ -1,12 +1,37 @@ -module github.com/AgentTel/AgentTel/examples/go-service-example +module github.com/AgentTel/agenttel-sdk/examples/go-service-example -go 1.22 +go 1.24.0 require ( - go.agenttel.dev/agenttel-go v0.1.0 - go.opentelemetry.io/otel v1.33.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 - go.opentelemetry.io/otel/sdk v1.33.0 + go.agenttel.dev/agenttel-go v0.4.0-alpha + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace go.agenttel.dev/agenttel-go => ../../agenttel-go diff --git a/examples/go-service-example/go.sum b/examples/go-service-example/go.sum new file mode 100644 index 0000000..d12456c --- /dev/null +++ b/examples/go-service-example/go.sum @@ -0,0 +1,76 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go-service-example/main.go b/examples/go-service-example/main.go index b81b7bd..b2f9105 100644 --- a/examples/go-service-example/main.go +++ b/examples/go-service-example/main.go @@ -11,12 +11,11 @@ import ( "os/signal" "time" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.24.0" "go.opentelemetry.io/otel/trace" agenttel "go.agenttel.dev/agenttel-go" @@ -27,54 +26,41 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - // Load AgentTel config - cfg, err := agenttel.LoadConfigFile("agenttel.yml") + // Build the AgentTel engine from agenttel.yml: topology, baselines and + // decision context (retryable, runbook, escalation) per "METHOD /route". + engine, err := agenttel.New().WithConfigFile("agenttel.yml").Build() if err != nil { - log.Printf("Warning: could not load agenttel.yml: %v (using defaults)", err) - cfg = agenttel.DefaultConfig() + log.Fatalf("Failed to build AgentTel engine: %v", err) } - // Set up OTel exporter - endpoint := envOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318") - exporter, err := otlptracehttp.New(ctx, - otlptracehttp.WithEndpoint(endpoint), - otlptracehttp.WithInsecure(), - ) + // Exporter: OTLP/HTTP by default, stdout with OTEL_TRACES_EXPORTER=console. + var exporter sdktrace.SpanExporter + if os.Getenv("OTEL_TRACES_EXPORTER") == "console" { + exporter, err = stdouttrace.New(stdouttrace.WithPrettyPrint()) + } else { + exporter, err = otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(envOrDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")), + otlptracehttp.WithInsecure(), + ) + } if err != nil { - log.Fatalf("Failed to create OTLP exporter: %v", err) + log.Fatalf("Failed to create exporter: %v", err) } - // Build AgentTel engine - engine := agenttel.NewEngineBuilder(cfg).Build() - - // Create TracerProvider with AgentTel processor - res, _ := resource.Merge( - resource.Default(), - resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceName(cfg.Topology.ServiceName), - semconv.ServiceVersion("1.0.0"), - attribute.String("agenttel.topology.team", cfg.Topology.Team), - attribute.String("agenttel.topology.tier", cfg.Topology.Tier), - ), - ) - + // TracerProvider with topology on the resource and the AgentTel processor. tp := sdktrace.NewTracerProvider( + sdktrace.WithResource(engine.Resource()), + sdktrace.WithSpanProcessor(engine.SpanProcessor()), sdktrace.WithBatcher(exporter), - sdktrace.WithSpanProcessor(engine.Processor()), - sdktrace.WithResource(res), ) - defer tp.Shutdown(ctx) + defer tp.Shutdown(context.Background()) otel.SetTracerProvider(tp) - tracer := otel.Tracer("go-payment-service") - // Set up HTTP routes with AgentTel middleware mux := http.NewServeMux() mux.HandleFunc("POST /api/payments", func(w http.ResponseWriter, r *http.Request) { - _, span := tracer.Start(r.Context(), "POST /api/payments", trace.WithSpanKind(trace.SpanKindServer)) - defer span.End() + span := trace.SpanFromContext(r.Context()) // the otelhttp server span // Simulate processing time.Sleep(time.Duration(30+rand.Intn(40)) * time.Millisecond) @@ -95,9 +81,6 @@ func main() { }) mux.HandleFunc("GET /api/payments/{id}", func(w http.ResponseWriter, r *http.Request) { - _, span := tracer.Start(r.Context(), "GET /api/payments/{id}", trace.WithSpanKind(trace.SpanKindServer)) - defer span.End() - id := r.PathValue("id") time.Sleep(time.Duration(10+rand.Intn(15)) * time.Millisecond) @@ -114,10 +97,10 @@ func main() { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) - // Wrap with AgentTel middleware - handler := agmw.Middleware(mux, - agmw.WithBaselineProvider(engine.BaselineProvider()), - agmw.WithTopology(engine.TopologyRegistry()), + // otelhttp creates the server span; AgentTel enriches it with topology, + // baselines and decisions for the matched "METHOD /route". + handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server", + otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { return r.Method }), ) port := envOrDefault("PORT", "8080") From 4015a9ebebac1861c27136376e38111f8df2dd44 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Fri, 16 Oct 2026 16:32:59 +0100 Subject: [PATCH 4/7] Release prep 0.4.0-alpha: versions, changelog, setup docs - Maven 0.4.0-alpha, npm 0.4.0-alpha.0, PyPI 0.4.0a1 - CHANGELOG entry for 0.4.0-alpha - README and quick-start: real Go/Node/Python setup code, correct minimum versions (Go 1.24, otel/sdk 1.39.0) Signed-off-by: Rohit Kumar --- CHANGELOG.md | 44 ++++++++++ README.md | 84 ++++++++++++-------- agenttel-types/package.json | 2 +- docs/getting-started/overview.md | 2 +- docs/getting-started/quick-start.md | 119 ++++++++++++++++------------ docs/index.md | 6 +- gradle.properties | 2 +- 7 files changed, 169 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f9489..a6b8393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to AgentTel are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0-alpha] - Unreleased + +Every SDK now reads the same `agenttel.yml` and puts the same attributes on HTTP server spans. Package versions: Maven `0.4.0-alpha`, npm `0.4.0-alpha.0`, PyPI `0.4.0a1`, Go `agenttel-go/v0.4.0-alpha`. + +### Shared configuration format (Python, Node.js, Go) + +All SDKs now accept the Spring Boot dialect: + +- `agenttel:` root, kebab-case keys (`on-call-channel`, `expected-latency-p50`, `runbook-url`) +- `operations` is a map keyed by `"METHOD /route"`. Spring-style `"[POST /api/payments]"` and bare keys are equivalent, and `:id` and `{id}` path parameters match each other +- latencies are duration strings (`45ms`, `1.5s`, `2m`) or plain numbers of milliseconds +- `profiles` hold shared decision defaults, and an operation's explicit values override its profile +- Each SDK's previous dialect (no root, snake_case or camelCase keys, Node's `operations` list) still loads and logs a deprecation warning + +### Python (`agenttel`) + +- **Fixed:** FastAPI server spans now get baselines and decisions. The middleware resolves the route template and enriches the active server span, so it no longer depends on the span name at `on_start`. `opentelemetry-instrumentation-fastapi` names that span `POST` at `on_start` and renames it after routing. +- **Fixed:** `instrument_fastapi()` reuses an already registered SDK `TracerProvider` instead of creating a second one. Topology is merged into its resource. It also calls `FastAPIInstrumentor.instrument_app()` when the app isn't instrumented yet. +- **Fixed:** decision attributes now use the `attributes.json` names (`agenttel.decision.*`, previously `agenttel.operation.*`), and on-call is now `agenttel.topology.on_call_channel` (previously `oncall_channel`). The old Python constant names remain as aliases. +- **Fixed:** `ConsumptionPattern.ASYNC` value is `async` (was `async_`); added `EscalationLevel.AUTO_RESOLVE` (new default, as on the JVM). +- **Fixed:** `examples/fastapi-example` imported the non-existent `BatchSpanExportProcessor` and used enum values the loader rejects. +- `AgentTelMiddleware` is now a pure ASGI middleware; `auto_configure()` delegates to `instrument_fastapi()`. + +### Node.js (`@agenttel/node`) + +- **Fixed:** the package loads under plain Node.js 18/20/22 as ESM (`import`) and CommonJS (`require`). Relative imports use `.js` extensions (enforced by building with `NodeNext`), and `dist/cjs/package.json` marks the CommonJS build. +- **Added:** Express and Fastify middleware set decision attributes (`retryable`, `idempotent`, `runbook_url`, `escalation_level`, `safe_to_restart`, `fallback_*`). Pass `{ engine }` to set topology, baselines and decisions in one go. Express re-checks the matched route template before the response ends, and Fastify records `http.route`. +- **Added:** `AgentTelEngine.fromConfigFile()`, `builder().withConfigFile()`, `engine.operations` (decision registry), `engine.resourceAttributes()`, `engine.topologyRegistry` alias, `parseConfig`, `normalizeOperationName`, `parseDurationMs`, `enrichOperation`. +- **Fixed:** the express/fastify examples now register `engine.spanProcessor` and HTTP instrumentation. +- Tests: real Express and Fastify servers with OTel instrumentation against the built package (`npm test`), and `npm run test:pack`, which installs the packed tarball into a fresh project. + +### Go (`go.agenttel.dev/agenttel-go`) + +- **Fixed:** YAML `operations` become static baselines and decision context in `Builder.Build()` (previously only `WithStaticBaselines` registered baselines). +- **Added:** `operations` package (`Registry`, `Decision`, `Normalize`, `ParseDurationMs`), `enrich` package, `Engine.Operations`, `Engine.Resource()` / `Engine.ResourceAttributes()`, `ParseConfig`, `LoadConfigFile` (alias of `LoadConfig`), and `WithEngine` / `WithDecisions` options for the net/http, Gin and gRPC middleware. +- **Added:** net/http middleware sets decision attributes and, for Go 1.22+ `ServeMux` patterns, records `http.route` and matches `GET /items/{id}` after routing. +- **Fixed:** `examples/go-service-example` and `examples/gin-example` compile and run against the real API (`agenttel.New().WithConfigFile(...).Build()`, `engine.SpanProcessor()`, `otelhttp` / `otelgin`). +- **Docs:** minimum versions are Go 1.24 and `go.opentelemetry.io/otel/sdk` v1.39.0 (previously documented as 1.22 / 1.33.0). +- **Breaking (config struct only):** YAML tags on `AgentTelConfig` are now kebab-case. camelCase files still load, with a warning. + +### JVM + +- No functional changes; version bump only. + ## [0.3.0-alpha] - 2026-03-20 ### Added diff --git a/README.md b/README.md index 900bd28..7b47cd0 100644 --- a/README.md +++ b/README.md @@ -219,21 +219,21 @@ AgentTel supports multiple integration paths — pick what fits your stack: dev.agenttel agenttel-spring-boot-starter - 0.3.0-alpha + 0.4.0-alpha dev.agenttel agenttel-genai - 0.3.0-alpha + 0.4.0-alpha dev.agenttel agenttel-agent - 0.3.0-alpha + 0.4.0-alpha ``` @@ -244,13 +244,13 @@ AgentTel supports multiple integration paths — pick what fits your stack: // build.gradle.kts dependencies { // Core: span enrichment, baselines, anomaly detection, SLO tracking - implementation("dev.agenttel:agenttel-spring-boot-starter:0.3.0-alpha") + implementation("dev.agenttel:agenttel-spring-boot-starter:0.4.0-alpha") // Optional: GenAI instrumentation - implementation("dev.agenttel:agenttel-genai:0.3.0-alpha") + implementation("dev.agenttel:agenttel-genai:0.4.0-alpha") // Optional: Agent interface layer (MCP server, incident context, remediation) - implementation("dev.agenttel:agenttel-agent:0.3.0-alpha") + implementation("dev.agenttel:agenttel-agent:0.4.0-alpha") } ``` @@ -438,7 +438,7 @@ app = FastAPI() instrument_fastapi(app) # One-line integration ``` -All spans are now enriched with topology, baselines, anomaly detection, and SLO tracking — identical attributes to the JVM SDK. +`instrument_fastapi()` attaches the AgentTel span processor to your existing OpenTelemetry `TracerProvider` (or creates one if none is set), instruments the app with `opentelemetry-instrumentation-fastapi`, and enriches each HTTP server span with topology, baselines and decision attributes for its `METHOD /route` — identical attributes to the JVM SDK. ### Backend: Go @@ -468,20 +468,31 @@ agenttel: ```go import ( + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + agenttel "go.agenttel.dev/agenttel-go" agmw "go.agenttel.dev/agenttel-go/middleware/http" ) -cfg, _ := agenttel.LoadConfigFile("agenttel.yml") -engine := agenttel.NewEngineBuilder(cfg).Build() +engine, err := agenttel.New().WithConfigFile("agenttel.yml").Build() +if err != nil { + log.Fatal(err) +} -// Wrap your handler with AgentTel middleware -handler := agmw.Middleware(mux, - agmw.WithBaselineProvider(engine.BaselineProvider()), - agmw.WithTopology(engine.TopologyRegistry()), +tp := sdktrace.NewTracerProvider( + sdktrace.WithResource(engine.Resource()), // topology on the resource + sdktrace.WithSpanProcessor(engine.SpanProcessor()), + sdktrace.WithBatcher(exporter), ) +otel.SetTracerProvider(tp) + +// otelhttp creates the server span; AgentTel enriches it for the matched "METHOD /route" +handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server") ``` +Requires `go.opentelemetry.io/otel/sdk` v1.39.0+. + Also supports **Gin** (`middleware/gin`) and **gRPC** (`middleware/grpc`) out of the box. See [Go Quickstart](docs/getting-started/go-quickstart.md) for details. ### Backend: Node.js (Express / Fastify) @@ -489,7 +500,7 @@ Also supports **Gin** (`middleware/gin`) and **gRPC** (`middleware/grpc`) out of #### 1. Install ```bash -npm install @agenttel/node @opentelemetry/api @opentelemetry/sdk-trace-node +npm install @agenttel/node @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/instrumentation-http ``` #### 2. Configure @@ -511,20 +522,23 @@ agenttel: #### 3. Instrument ```typescript -import { AgentTelEngineBuilder, expressMiddleware, loadConfig } from '@agenttel/node'; +import { AgentTelEngine, expressMiddleware } from '@agenttel/node'; -const config = loadConfig('agenttel.yml'); -const engine = new AgentTelEngineBuilder() - .withTeam(config.topology?.team ?? 'my-team') - .withTier(config.topology?.tier ?? 'critical') - .build(); +const engine = AgentTelEngine.fromConfigFile('agenttel.yml'); -app.use(expressMiddleware({ - baselineProvider: engine.baselineProvider, - topology: engine.topologyRegistry, -})); +// Register the processor with your tracer provider (alongside your exporter) +const provider = new NodeTracerProvider({ + resource: new Resource(engine.resourceAttributes()), + spanProcessors: [engine.spanProcessor, new BatchSpanProcessor(exporter)], +}); +provider.register(); +registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()] }); + +app.use(expressMiddleware({ engine })); // topology, baselines and decisions on the server span ``` +Works under plain `node` as ESM (`import`) or CommonJS (`require`), Node.js 18+. + Also supports **Fastify** (`fastifyPlugin`). See [Node.js Quickstart](docs/getting-started/nodejs-quickstart.md) for details. ### Frontend: Browser SDK @@ -686,19 +700,19 @@ Each example includes a README with step-by-step instructions and curl commands | Component | Supported Versions | |-----------|--------------------| -| Go | 1.22+ | -| OpenTelemetry SDK | 1.33.0+ | -| net/http | Standard library | -| Gin | 1.9+ (optional) | -| gRPC | 1.60+ (optional) | +| Go | 1.24+ | +| OpenTelemetry SDK (`go.opentelemetry.io/otel/sdk`) | 1.39.0+ | +| net/http | Standard library (+ `otelhttp` for server spans) | +| Gin | 1.11+ with `otelgin` (optional) | +| gRPC | 1.79+ (optional) | ### Backend (Node.js) | Component | Supported Versions | |-----------|--------------------| -| Node.js | 18+ | +| Node.js | 18+ (ESM and CommonJS) | | TypeScript | 5.0+ | -| OpenTelemetry SDK | 1.30.0+ | +| OpenTelemetry JS SDK | 1.25.0+ (1.x) | | Express | 4.x / 5.x (optional) | | Fastify | 4.x+ (optional) | @@ -738,20 +752,20 @@ AgentTel publishes standard Maven artifacts to Maven Central. Your application c dev.agenttel agenttel-spring-boot-starter - 0.3.0-alpha + 0.4.0-alpha ``` ### Gradle (Kotlin DSL) ```kotlin -implementation("dev.agenttel:agenttel-spring-boot-starter:0.3.0-alpha") +implementation("dev.agenttel:agenttel-spring-boot-starter:0.4.0-alpha") ``` ### Gradle (Groovy DSL) ```groovy -implementation 'dev.agenttel:agenttel-spring-boot-starter:0.3.0-alpha' +implementation 'dev.agenttel:agenttel-spring-boot-starter:0.4.0-alpha' ``` ### All Available Artifacts @@ -814,7 +828,7 @@ See [FAQ.md](FAQ.md) for frequently asked questions about code changes, performa ./gradlew test ./gradlew :agenttel-agent:build -# Go (requires Go 1.22+) +# Go (requires Go 1.24+) cd agenttel-go && go test -race ./... # Node.js (requires Node.js 18+) diff --git a/agenttel-types/package.json b/agenttel-types/package.json index 61b3ed9..982648b 100644 --- a/agenttel-types/package.json +++ b/agenttel-types/package.json @@ -1,6 +1,6 @@ { "name": "@agenttel/types", - "version": "0.3.0-alpha.0", + "version": "0.4.0-alpha.0", "description": "Shared types, enums, and attribute constants for AgentTel SDKs", "type": "module", "main": "./dist/cjs/index.js", diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 630f334..d71b61f 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -121,7 +121,7 @@ agenttel/ ## Current Status -AgentTel is in **alpha** (v0.2.0-alpha). The core instrumentation, GenAI support, and agent interface layer are implemented and tested. The API surface may evolve before 1.0. +AgentTel is in **alpha** (v0.4.0-alpha). The core instrumentation, GenAI support, and agent interface layer are implemented and tested. The API surface may evolve before 1.0. ## Further Reading diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index ca9825f..8b19a71 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -50,21 +50,21 @@ Teardown: `docker compose -f docker/docker-compose.yml down -v` dev.agenttel agenttel-spring-boot-starter - 0.2.0-alpha + 0.4.0-alpha dev.agenttel agenttel-genai - 0.2.0-alpha + 0.4.0-alpha dev.agenttel agenttel-agent - 0.2.0-alpha + 0.4.0-alpha ``` @@ -74,13 +74,13 @@ Teardown: `docker compose -f docker/docker-compose.yml down -v` ```kotlin dependencies { // Core: span enrichment, baselines, anomaly detection, SLO tracking - implementation("dev.agenttel:agenttel-spring-boot-starter:0.2.0-alpha") + implementation("dev.agenttel:agenttel-spring-boot-starter:0.4.0-alpha") // Optional: GenAI instrumentation - implementation("dev.agenttel:agenttel-genai:0.2.0-alpha") + implementation("dev.agenttel:agenttel-genai:0.4.0-alpha") // Optional: Agent interface layer (MCP server, incident context, remediation) - implementation("dev.agenttel:agenttel-agent:0.2.0-alpha") + implementation("dev.agenttel:agenttel-agent:0.4.0-alpha") } ``` @@ -89,13 +89,13 @@ Teardown: `docker compose -f docker/docker-compose.yml down -v` ```groovy dependencies { // Core: span enrichment, baselines, anomaly detection, SLO tracking - implementation 'dev.agenttel:agenttel-spring-boot-starter:0.2.0-alpha' + implementation 'dev.agenttel:agenttel-spring-boot-starter:0.4.0-alpha' // Optional: GenAI instrumentation - implementation 'dev.agenttel:agenttel-genai:0.2.0-alpha' + implementation 'dev.agenttel:agenttel-genai:0.4.0-alpha' // Optional: Agent interface layer (MCP server, incident context, remediation) - implementation 'dev.agenttel:agenttel-agent:0.2.0-alpha' + implementation 'dev.agenttel:agenttel-agent:0.4.0-alpha' } ``` @@ -274,45 +274,63 @@ agenttel: ```go import ( + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + agenttel "go.agenttel.dev/agenttel-go" agmw "go.agenttel.dev/agenttel-go/middleware/http" ) - cfg, _ := agenttel.LoadConfigFile("agenttel.yml") - engine := agenttel.NewEngineBuilder(cfg).Build() + engine, err := agenttel.New().WithConfigFile("agenttel.yml").Build() + if err != nil { + log.Fatal(err) + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithResource(engine.Resource()), + sdktrace.WithSpanProcessor(engine.SpanProcessor()), + sdktrace.WithBatcher(exporter), + ) + otel.SetTracerProvider(tp) mux := http.NewServeMux() - // ... register handlers ... + mux.HandleFunc("POST /api/payments", createPayment) - handler := agmw.Middleware(mux, - agmw.WithBaselineProvider(engine.BaselineProvider()), - agmw.WithTopology(engine.TopologyRegistry()), - ) + // otelhttp creates the server span; AgentTel enriches it for the matched route + handler := otelhttp.NewHandler(agmw.Middleware(agmw.WithEngine(engine))(mux), "server") http.ListenAndServe(":8080", handler) ``` === "Gin" ```go - import agmw "go.agenttel.dev/agenttel-go/middleware/gin" + import ( + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + aggin "go.agenttel.dev/agenttel-go/middleware/gin" + ) - r := gin.Default() - r.Use(agmw.Middleware(engine.BaselineProvider(), engine.TopologyRegistry())) + cfg := aggin.NewConfig(aggin.WithEngine(engine)) + r := gin.New() + r.Use(otelgin.Middleware("payment-service")) + r.Use(func(c *gin.Context) { + aggin.EnrichSpan(trace.SpanFromContext(c.Request.Context()), c.Request.Method, c.FullPath(), cfg) + c.Next() + }) ``` === "gRPC" ```go - import agmw "go.agenttel.dev/agenttel-go/middleware/grpc" + import aggrpc "go.agenttel.dev/agenttel-go/middleware/grpc" + // Operations are keyed by full method name, e.g. "/payments.v1.Payments/Charge" srv := grpc.NewServer( - grpc.UnaryInterceptor(agmw.UnaryServerInterceptor( - engine.BaselineProvider(), engine.TopologyRegistry(), - )), + grpc.StatsHandler(otelgrpc.NewServerHandler()), + grpc.UnaryInterceptor(aggrpc.UnaryServerInterceptor(aggrpc.WithEngine(engine))), ) ``` -All spans are automatically enriched with topology, baselines, anomaly detection, and SLO tracking — identical attributes to the JVM SDK. +HTTP server spans get topology, static baselines and decision attributes (`retryable`, `runbook_url`, `escalation_level`, ...) for their `METHOD /route` — identical attributes to the JVM SDK. ### 4. GenAI Instrumentation (Optional) @@ -346,9 +364,9 @@ defer step.End() | Component | Versions | |-----------|----------| -| Go | 1.22+ | -| OpenTelemetry SDK | 1.33.0+ | -| net/http, Gin, gRPC | Latest | +| Go | 1.24+ | +| OpenTelemetry SDK (`go.opentelemetry.io/otel/sdk`) | 1.39.0+ | +| net/http (+ `otelhttp`), Gin 1.11+ (+ `otelgin`), gRPC 1.79+ | | --- @@ -386,19 +404,24 @@ agenttel: === "Express" ```typescript - import { AgentTelEngineBuilder, expressMiddleware, loadConfig } from '@agenttel/node'; - - const config = loadConfig('agenttel.yml'); - const engine = new AgentTelEngineBuilder() - .withTeam(config.topology?.team ?? 'payments-platform') - .withTier(config.topology?.tier ?? 'critical') - .build(); + import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; + import { Resource } from '@opentelemetry/resources'; + import { registerInstrumentations } from '@opentelemetry/instrumentation'; + import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; + import { AgentTelEngine, expressMiddleware } from '@agenttel/node'; + + const engine = AgentTelEngine.fromConfigFile('agenttel.yml'); + const provider = new NodeTracerProvider({ + resource: new Resource(engine.resourceAttributes()), + spanProcessors: [engine.spanProcessor, new BatchSpanProcessor(exporter)], + }); + provider.register(); + registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()] }); const app = express(); - app.use(expressMiddleware({ - baselineProvider: engine.baselineProvider, - topology: engine.topologyRegistry, - })); + app.use(expressMiddleware({ engine })); ``` === "Fastify" @@ -407,14 +430,12 @@ agenttel: import Fastify from 'fastify'; import { fastifyPlugin } from '@agenttel/node'; + // same provider + HttpInstrumentation setup as for Express const fastify = Fastify(); - fastify.register(fastifyPlugin, { - baselineProvider: engine.baselineProvider, - topology: engine.topologyRegistry, - }); + await fastify.register(fastifyPlugin, { engine }); ``` -All spans are automatically enriched with topology, baselines, anomaly detection, and SLO tracking — identical attributes to the JVM SDK. +HTTP server spans get topology, static baselines and decision attributes (`retryable`, `runbook_url`, `escalation_level`, ...) for their `METHOD /route` — identical attributes to the JVM SDK. The package loads under plain `node` as ESM or CommonJS. ### 4. GenAI Instrumentation (Optional) @@ -447,10 +468,10 @@ inv.end(); | Component | Versions | |-----------|----------| -| Node.js | 18+ | +| Node.js | 18+ (ESM and CommonJS) | | TypeScript | 5.0+ | -| OpenTelemetry SDK | 1.30.0+ | -| Express, Fastify | Latest | +| OpenTelemetry JS SDK | 1.25.0+ (1.x) | +| Express 4/5, Fastify 4/5 | | --- @@ -496,7 +517,7 @@ app = FastAPI() instrument_fastapi(app) # One-line integration ``` -All spans are automatically enriched with topology, baselines, anomaly detection, and SLO tracking — identical attributes to the JVM SDK. +`instrument_fastapi()` attaches the AgentTel span processor to your existing OpenTelemetry `TracerProvider` (or creates one if none is set) and instruments the app with `opentelemetry-instrumentation-fastapi`. HTTP server spans get topology, static baselines and decision attributes for their `METHOD /route` — identical attributes to the JVM SDK. ### 4. GenAI Instrumentation (Optional) @@ -674,14 +695,14 @@ Add observability to your AI agent's lifecycle — invocations, reasoning steps, dev.agenttel agenttel-agentic - 0.2.0-alpha + 0.4.0-alpha ``` === "Gradle (Kotlin)" ```kotlin - implementation("dev.agenttel:agenttel-agentic:0.2.0-alpha") + implementation("dev.agenttel:agenttel-agentic:0.4.0-alpha") ``` ### 2. Instrument Your Agent diff --git a/docs/index.md b/docs/index.md index acdb26b..19346fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -334,9 +334,9 @@ Affected Deps: stripe-api | Component | Supported Versions | |-----------|--------------------| -| Go | 1.22+ | -| OpenTelemetry SDK | 1.33.0+ | -| net/http, Gin, gRPC | Latest | +| Go | 1.24+ | +| OpenTelemetry SDK (`go.opentelemetry.io/otel/sdk`) | 1.39.0+ | +| net/http (+ `otelhttp`), Gin 1.11+ (+ `otelgin`), gRPC 1.79+ | | **Backend (Node.js)** diff --git a/gradle.properties b/gradle.properties index f87772d..71841f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=dev.agenttel -VERSION_NAME=0.3.0-alpha +VERSION_NAME=0.4.0-alpha POM_URL=https://github.com/agenttel/agenttel-sdk POM_SCM_URL=https://github.com/agenttel/agenttel-sdk POM_SCM_CONNECTION=scm:git:git://github.com/agenttel/agenttel-sdk.git From 1932f6e137e478b30bc00f4150bc99c9fda3148f Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Fri, 16 Oct 2026 16:40:11 +0100 Subject: [PATCH 5/7] Javaagent: enrich HTTP server spans with baselines and decisions Server spans from the OTel javaagent start with a provisional name ("POST") and get their route later, so AgentTelSpanProcessor's onStart lookup missed them and -javaagent runs exported no agenttel.* attributes. - AgentTelEnrichingSpanExporter.withOperationMetadata(): add static baselines and decisions at export time, resolved from the final span name or METHOD http.route, when the span did not get them at start - Javaagent: enable it, strip [brackets] from operation keys, and add topology via a resource customizer (independent of SPI discovery) - Project version comes from VERSION_NAME (jar names said 0.2.0-alpha) - JavaagentIntegrationTest: plain Spring Boot app run with the built agent jar, one request, assert server span and resource Signed-off-by: Rohit Kumar --- CHANGELOG.md | 5 +- .../export/AgentTelEnrichingSpanExporter.java | 71 +++++++ agenttel-javaagent/build.gradle.kts | 24 +++ .../AgentTelExtensionCustomizer.java | 28 ++- .../it/JavaagentIntegrationTest.java | 188 ++++++++++++++++++ .../extension/it/PaymentsTestApp.java | 29 +++ build.gradle.kts | 2 +- 7 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 agenttel-javaagent/src/test/java/io/agenttel/extension/it/JavaagentIntegrationTest.java create mode 100644 agenttel-javaagent/src/test/java/io/agenttel/extension/it/PaymentsTestApp.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b8393..8272b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,10 @@ All SDKs now accept the Spring Boot dialect: ### JVM -- No functional changes; version bump only. +- **Fixed (javaagent):** HTTP server spans now get baselines and decisions. Previously `-javaagent` runs added no `agenttel.*` attributes to Tomcat/servlet server spans: those start as `POST` and only get their route name later, so the processor's start-time lookup missed them. The Spring Boot starter has a route-aware interceptor, but the agent had nothing equivalent. `AgentTelEnrichingSpanExporter.withOperationMetadata(...)` now adds static baselines and decisions at export time, resolved from the final span name or `METHOD http.route`. The javaagent enables it. +- **Fixed (javaagent):** `"[POST /api/payments]"` keys work in the agent's `agenttel.yml` (brackets were kept literally). Topology is now added to the Resource through a resource customizer, so it no longer depends on ResourceProvider SPI discovery. +- **Fixed (build):** jar file names and javadoc titles used a hard-coded `0.2.0-alpha` project version. The project version now comes from `VERSION_NAME`; Maven coordinates were already correct. +- Added `JavaagentIntegrationTest`: it runs a plain Spring Boot app with `-javaagent:`, sends one request and asserts the exported server span and resource. ## [0.3.0-alpha] - 2026-03-20 diff --git a/agenttel-core/src/main/java/io/agenttel/core/export/AgentTelEnrichingSpanExporter.java b/agenttel-core/src/main/java/io/agenttel/core/export/AgentTelEnrichingSpanExporter.java index 91db163..b4a1c29 100644 --- a/agenttel-core/src/main/java/io/agenttel/core/export/AgentTelEnrichingSpanExporter.java +++ b/agenttel-core/src/main/java/io/agenttel/core/export/AgentTelEnrichingSpanExporter.java @@ -7,9 +7,11 @@ import io.agenttel.core.anomaly.AnomalyResult; import io.agenttel.core.anomaly.IncidentPattern; import io.agenttel.core.anomaly.PatternMatcher; +import io.agenttel.core.baseline.BaselineProvider; import io.agenttel.core.baseline.RollingBaselineProvider; import io.agenttel.core.baseline.RollingWindow; import io.agenttel.core.causality.CausalityTracker; +import io.agenttel.core.enrichment.OperationContextRegistry; import io.agenttel.core.error.ErrorClassifier; import io.agenttel.core.slo.SloTracker; import io.agenttel.core.topology.TopologyRegistry; @@ -37,6 +39,10 @@ *
  • {@code agenttel.cause.*} — causal analysis
  • *
  • {@code agenttel.severity.*} — severity assessment
  • *
  • {@code agenttel.baseline.sample_count/confidence} — baseline reliability
  • + *
  • {@code agenttel.baseline.*} and {@code agenttel.decision.*} for configured operations, + * when {@link #withOperationMetadata} is set and the span did not get them at start. + * HTTP server spans start with a provisional name (e.g. {@code POST}) and only get + * their route ({@code POST /api/payments}, {@code http.route}) after routing.
  • * */ public class AgentTelEnrichingSpanExporter implements SpanExporter { @@ -48,6 +54,8 @@ public class AgentTelEnrichingSpanExporter implements SpanExporter { private final AnomalyDetector anomalyDetector; private final ErrorClassifier errorClassifier; private final TopologyRegistry topology; + private BaselineProvider staticBaselines; + private OperationContextRegistry operationContexts; public AgentTelEnrichingSpanExporter(SpanExporter delegate, RollingBaselineProvider rollingBaselines, @@ -65,6 +73,17 @@ public AgentTelEnrichingSpanExporter(SpanExporter delegate, this.topology = topology; } + /** + * Also add static baseline and decision attributes at export time, resolved from the + * final span name or {@code METHOD http.route}, for spans that missed them at start. + */ + public AgentTelEnrichingSpanExporter withOperationMetadata(BaselineProvider staticBaselines, + OperationContextRegistry operationContexts) { + this.staticBaselines = staticBaselines; + this.operationContexts = operationContexts; + return this; + } + @Override public CompletableResultCode export(Collection spans) { List enriched = new ArrayList<>(spans.size()); @@ -91,6 +110,9 @@ private SpanData enrich(SpanData span) { / TimeUnit.MILLISECONDS.toNanos(1); boolean isError = span.getStatus().getStatusCode() == StatusCode.ERROR; + // --- Static baselines + decisions missed at span start --- + enrichOperationMetadata(extra, span); + // --- Anomaly detection --- enrichAnomaly(extra, operationName, latencyMs, isError); @@ -121,6 +143,55 @@ private SpanData enrich(SpanData span) { return new EnrichedSpanData(span, extra); } + private void enrichOperationMetadata(Map, Object> extra, SpanData span) { + if (staticBaselines == null && operationContexts == null) return; + var attrs = span.getAttributes(); + boolean needBaseline = staticBaselines != null + && attrs.get(AgentTelAttributes.BASELINE_SOURCE) == null; + boolean needDecision = operationContexts != null + && attrs.get(AgentTelAttributes.DECISION_RETRYABLE) == null; + if (!needBaseline && !needDecision) return; + + List candidates = new ArrayList<>(2); + candidates.add(span.getName()); + String route = attrs.get(AttributeKey.stringKey("http.route")); + String method = attrs.get(AttributeKey.stringKey("http.request.method")); + if (method == null) method = attrs.get(AttributeKey.stringKey("http.method")); + if (route != null && method != null) candidates.add(method + " " + route); + + for (String name : candidates) { + if (needBaseline) { + var baseline = staticBaselines.getBaseline(name); + if (baseline.isPresent()) { + var b = baseline.get(); + if (b.latencyP50Ms() > 0) extra.put(AgentTelAttributes.BASELINE_LATENCY_P50_MS, b.latencyP50Ms()); + if (b.latencyP99Ms() > 0) extra.put(AgentTelAttributes.BASELINE_LATENCY_P99_MS, b.latencyP99Ms()); + if (b.errorRate() >= 0) extra.put(AgentTelAttributes.BASELINE_ERROR_RATE, b.errorRate()); + extra.put(AgentTelAttributes.BASELINE_SOURCE, b.source().getValue()); + needBaseline = false; + } + } + if (needDecision) { + var context = operationContexts.getContext(name); + if (context.isPresent()) { + var ctx = context.get(); + extra.put(AgentTelAttributes.DECISION_RETRYABLE, ctx.isRetryable()); + extra.put(AgentTelAttributes.DECISION_IDEMPOTENT, ctx.isIdempotent()); + if (!ctx.getRunbookUrl().isEmpty()) { + extra.put(AgentTelAttributes.DECISION_RUNBOOK_URL, ctx.getRunbookUrl()); + } + if (!ctx.getFallbackDescription().isEmpty()) { + extra.put(AgentTelAttributes.DECISION_FALLBACK_AVAILABLE, true); + extra.put(AgentTelAttributes.DECISION_FALLBACK_DESCRIPTION, ctx.getFallbackDescription()); + } + extra.put(AgentTelAttributes.DECISION_ESCALATION_LEVEL, ctx.getEscalationLevel().getValue()); + extra.put(AgentTelAttributes.DECISION_SAFE_TO_RESTART, ctx.isSafeToRestart()); + needDecision = false; + } + } + } + } + private void enrichAnomaly(Map, Object> extra, String operationName, double latencyMs, boolean isError) { if (anomalyDetector == null || rollingBaselines == null) return; diff --git a/agenttel-javaagent/build.gradle.kts b/agenttel-javaagent/build.gradle.kts index ee73d0a..c31806d 100644 --- a/agenttel-javaagent/build.gradle.kts +++ b/agenttel-javaagent/build.gradle.kts @@ -11,6 +11,13 @@ val otel: Configuration by configurations.creating { isCanBeConsumed = false } +// Runtime classpath of the plain Spring Boot app started by JavaagentIntegrationTest +// (no AgentTel or OpenTelemetry on it; AgentTel is attached with -javaagent) +val integrationApp: Configuration by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false +} + dependencies { implementation(project(":agenttel-core")) @@ -22,6 +29,12 @@ dependencies { testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + val springBootVersion = rootProject.extra["springBootVersion"] + testCompileOnly(platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")) + testCompileOnly("org.springframework.boot:spring-boot-starter-web") + integrationApp(platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")) + integrationApp("org.springframework.boot:spring-boot-starter-web") + // Upstream OTel javaagent (downloaded, not on compile classpath) otel("io.opentelemetry.javaagent:opentelemetry-javaagent:${rootProject.extra["otelInstrumentationVersion"]}") } @@ -70,6 +83,17 @@ tasks.named("assemble") { dependsOn("agentJar") } +tasks.test { + val agentJar = tasks.named("agentJar") + dependsOn(agentJar) + inputs.files(integrationApp) + doFirst { + systemProperty("agenttel.it.agentJar", agentJar.get().archiveFile.get().asFile.absolutePath) + systemProperty("agenttel.it.appClasspath", + (sourceSets["test"].output.classesDirs.files + integrationApp.files).joinToString(File.pathSeparator)) + } +} + // Wire the agentJar as the main artifact for publishing afterEvaluate { val agentJarTask = tasks.named("agentJar") diff --git a/agenttel-javaagent/src/main/java/io/agenttel/extension/AgentTelExtensionCustomizer.java b/agenttel-javaagent/src/main/java/io/agenttel/extension/AgentTelExtensionCustomizer.java index 4464716..0133b4b 100644 --- a/agenttel-javaagent/src/main/java/io/agenttel/extension/AgentTelExtensionCustomizer.java +++ b/agenttel-javaagent/src/main/java/io/agenttel/extension/AgentTelExtensionCustomizer.java @@ -22,6 +22,7 @@ import io.agenttel.core.error.ErrorClassifier; import io.agenttel.core.export.AgentTelEnrichingSpanExporter; import io.agenttel.core.resource.AgentTelGlobalState; +import io.agenttel.core.resource.AgentTelResourceProvider; import io.agenttel.core.slo.SloTracker; import io.agenttel.core.topology.TopologyRegistry; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; @@ -86,11 +87,19 @@ public void customize(AutoConfigurationCustomizer customizer) { customizer.addTracerProviderCustomizer( (builder, cfg) -> builder.addSpanProcessor(processor)); - // 6. Register enriching exporter to add computed attributes at export time + // 6. Register enriching exporter to add computed attributes at export time. + // It also adds baselines and decisions to HTTP server spans: those start with a + // provisional name ("POST") and only get their route after routing, so the + // processor's onStart lookup misses them. customizer.addSpanExporterCustomizer( (exporter, cfg) -> new AgentTelEnrichingSpanExporter( exporter, rollingBaselines, sloTracker, causalityTracker, - anomalyDetector, errorClassifier, topology)); + anomalyDetector, errorClassifier, topology) + .withOperationMetadata(baselines, opContexts)); + + // 7. Topology on the Resource (independent of ResourceProvider SPI discovery) + customizer.addResourceCustomizer( + (resource, cfg) -> resource.merge(new AgentTelResourceProvider().createResource(cfg))); logger.info("AgentTel extension initialized — topology: team=" + topology.getTeam() + ", tier=" + topology.getTier().getValue() @@ -149,7 +158,7 @@ private static void registerOperations(AgentTelConfig config, StaticBaselineProvider baselines, OperationContextRegistry opContexts) { for (var entry : config.getOperations().entrySet()) { - String operationName = entry.getKey(); + String operationName = normalizeOperationName(entry.getKey()); var op = entry.getValue(); // Resolve profile defaults @@ -202,4 +211,17 @@ private static void registerOperations(AgentTelConfig config, fallbackDesc, escalation, safeToRestart)); } } + + /** + * Strips Spring-style {@code [brackets]} from an operation key. Spring Boot needs + * them in map keys containing spaces or slashes; the agent's YAML loader keeps them + * literally, so both {@code "[POST /api/payments]"} and {@code "POST /api/payments"} work. + */ + static String normalizeOperationName(String name) { + String n = name.trim(); + if (n.startsWith("[") && n.endsWith("]")) { + n = n.substring(1, n.length() - 1).trim(); + } + return n; + } } diff --git a/agenttel-javaagent/src/test/java/io/agenttel/extension/it/JavaagentIntegrationTest.java b/agenttel-javaagent/src/test/java/io/agenttel/extension/it/JavaagentIntegrationTest.java new file mode 100644 index 0000000..2b5b896 --- /dev/null +++ b/agenttel-javaagent/src/test/java/io/agenttel/extension/it/JavaagentIntegrationTest.java @@ -0,0 +1,188 @@ +package io.agenttel.extension.it; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * End-to-end, mirroring the Spring Boot EnrichmentTest: a plain Spring Boot app is + * started with {@code -javaagent:agenttel-javaagent.jar} (the built artifact), one + * request is sent, and the exported Tomcat SERVER span must carry baselines and + * decisions, with topology on the resource. + */ +class JavaagentIntegrationTest { + + private static final String CONFIG = """ + agenttel: + topology: + team: payments-platform + tier: critical + domain: commerce + on-call-channel: "#payments-oncall" + operations: + "[POST /api/payments]": + expected-latency-p50: "45ms" + expected-latency-p99: "200ms" + expected-error-rate: 0.001 + retryable: true + runbook-url: "https://wiki.example.com/runbooks/payments" + "GET /api/payments/{id}": + expected-latency-p50: "15ms" + expected-latency-p99: "80ms" + """; + + @TempDir + static Path tmp; + + private static Process app; + private static Path log; + private static int port; + + @BeforeAll + static void startAppWithAgent() throws Exception { + String agentJar = System.getProperty("agenttel.it.agentJar"); + String classpath = System.getProperty("agenttel.it.appClasspath"); + assertThat(agentJar).as("run via Gradle: agenttel.it.agentJar").isNotNull(); + + Path config = Files.writeString(tmp.resolve("agenttel.yml"), CONFIG); + log = tmp.resolve("app.log"); + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + String java = ProcessHandle.current().info().command().orElse("java"); + app = new ProcessBuilder(java, + "-javaagent:" + agentJar, + "-Dagenttel.config.file=" + config, + "-Dotel.service.name=payment-service", + "-Dotel.traces.exporter=logging-otlp", + "-Dotel.metrics.exporter=none", + "-Dotel.logs.exporter=none", + "-Dotel.bsp.schedule.delay=100", + "-Dserver.port=" + port, + "-cp", classpath, + PaymentsTestApp.class.getName()) + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + } + + @AfterAll + static void stopApp() throws Exception { + if (app != null) { + app.destroy(); + app.waitFor(10, TimeUnit.SECONDS); + } + } + + @Test + void serverSpansAreEnriched() throws Exception { + HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + send(http, "POST", "/api/payments", Duration.ofSeconds(120)); + send(http, "GET", "/api/payments/pay_123", Duration.ofSeconds(10)); + + Map> serverSpans = new HashMap<>(); + Map resource = new HashMap<>(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (serverSpans.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(250); + serverSpans.clear(); + collectServerSpans(Files.readAllLines(log), serverSpans, resource); + } + String output = Files.readString(log); + assertThat(serverSpans).as(output).containsKeys("POST /api/payments", "GET /api/payments/{id}"); + + Map post = serverSpans.get("POST /api/payments"); + assertThat(post).as("POST span attributes") + .containsEntry("agenttel.baseline.latency_p50_ms", 45.0) + .containsEntry("agenttel.baseline.latency_p99_ms", 200.0) + .containsEntry("agenttel.baseline.error_rate", 0.001) + .containsEntry("agenttel.baseline.source", "static") + .containsEntry("agenttel.decision.retryable", true) + .containsEntry("agenttel.decision.runbook_url", "https://wiki.example.com/runbooks/payments"); + + Map get = serverSpans.get("GET /api/payments/{id}"); + assertThat(get).containsEntry("agenttel.baseline.latency_p50_ms", 15.0) + .containsEntry("agenttel.decision.retryable", false); + + assertThat(resource) + .containsEntry("agenttel.topology.team", "payments-platform") + .containsEntry("agenttel.topology.tier", "critical") + .containsEntry("agenttel.topology.on_call_channel", "#payments-oncall"); + } + + private static void send(HttpClient http, String method, String path, Duration timeout) throws Exception { + long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + assertThat(app.isAlive()).as(() -> read(log)).isTrue(); + try { + HttpResponse response = http.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .method(method, HttpRequest.BodyPublishers.noBody()).timeout(Duration.ofSeconds(5)).build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(method + " " + path + ": " + response.body()).isEqualTo(200); + return; + } catch (IOException notUpYet) { + if (System.nanoTime() > deadline) throw notUpYet; + Thread.sleep(500); + } + } + } + + /** Parses OtlpJsonLoggingSpanExporter lines (one ResourceSpans JSON object per line). */ + private static void collectServerSpans(List lines, Map> spans, + Map resource) throws IOException { + ObjectMapper json = new ObjectMapper(); + for (String line : lines) { + int start = line.indexOf("{\"resource\""); + if (start < 0) continue; + JsonNode resourceSpans = json.readTree(line.substring(start)); + resource.putAll(attributes(resourceSpans.path("resource").path("attributes"))); + for (JsonNode scope : resourceSpans.path("scopeSpans")) { + for (JsonNode span : scope.path("spans")) { + if (span.path("kind").asInt() == 2) { // SPAN_KIND_SERVER + spans.put(span.path("name").asText(), attributes(span.path("attributes"))); + } + } + } + } + } + + private static Map attributes(JsonNode list) { + Map out = new HashMap<>(); + for (JsonNode kv : list) { + JsonNode v = kv.path("value"); + Object value = v.has("stringValue") ? v.get("stringValue").asText() + : v.has("boolValue") ? v.get("boolValue").asBoolean() + : v.has("doubleValue") ? v.get("doubleValue").asDouble() + : v.has("intValue") ? (Object) v.get("intValue").asLong() + : v.toString(); + out.put(kv.path("key").asText(), value); + } + return out; + } + + private static String read(Path p) { + try { + return Files.readString(p); + } catch (IOException e) { + return ""; + } + } +} diff --git a/agenttel-javaagent/src/test/java/io/agenttel/extension/it/PaymentsTestApp.java b/agenttel-javaagent/src/test/java/io/agenttel/extension/it/PaymentsTestApp.java new file mode 100644 index 0000000..b5bd252 --- /dev/null +++ b/agenttel-javaagent/src/test/java/io/agenttel/extension/it/PaymentsTestApp.java @@ -0,0 +1,29 @@ +package io.agenttel.extension.it; + +import java.util.Map; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Plain Spring Boot app (no AgentTel dependency) started by {@link JavaagentIntegrationTest}. */ +@SpringBootApplication +@RestController +public class PaymentsTestApp { + + public static void main(String[] args) { + SpringApplication.run(PaymentsTestApp.class, args); + } + + @PostMapping("/api/payments") + public Map pay() { + return Map.of("status", "completed"); + } + + @GetMapping("/api/payments/{id}") + public Map get(@PathVariable("id") String id) { + return Map.of("id", id); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 7249340..913a8ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,7 @@ extra["bedrockSdkVersion"] = "2.30.0" allprojects { group = "dev.agenttel" - version = "0.2.0-alpha" + version = rootProject.property("VERSION_NAME") as String repositories { mavenCentral() From cff9a8568b19d7c92092da74f0aad9aad6160893 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Tue, 20 Oct 2026 12:15:08 +0100 Subject: [PATCH 6/7] CI: build Node SDK before tests, pin ruff rules and version - node-test ran vitest without building, so the integration test (which loads dist/ under plain node) found no package; use npm test (pretest builds) and add the packed-tarball smoke test - Newer ruff releases enable more rule families by default (main itself now fails with 84 findings); select E4/E7/E9/F explicitly and pin ruff Signed-off-by: Rohit Kumar --- .github/workflows/ci.yml | 8 ++++++-- agenttel-python/pyproject.toml | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13b85b6..c9b1357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,11 @@ jobs: - name: Run tests working-directory: agenttel-node - run: npx vitest run + run: npm test # pretest builds dist/, which the integration test runs under plain node + + - name: Packed tarball smoke test (ESM import, CJS require, Express/Fastify) + working-directory: agenttel-node + run: node scripts/pack-smoke.mjs python-test: runs-on: ubuntu-latest @@ -118,7 +122,7 @@ jobs: - name: Lint working-directory: agenttel-python run: | - pip install ruff + pip install ruff==0.17.1 ruff check src/ tests/ - name: Run tests diff --git a/agenttel-python/pyproject.toml b/agenttel-python/pyproject.toml index ff7bc48..dddaa82 100644 --- a/agenttel-python/pyproject.toml +++ b/agenttel-python/pyproject.toml @@ -48,6 +48,11 @@ agenttel = "agenttel:AgentTelSpanProcessor" [tool.hatch.build.targets.wheel] packages = ["src/agenttel"] +[tool.ruff.lint] +# Explicit rule set: newer ruff releases enable more rule families by default, +# which made CI results depend on the ruff version. +select = ["E4", "E7", "E9", "F"] + [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" From 6ffcf6b3fd6f10ecc5eeba2c8ad47c0f32eeedbc Mon Sep 17 00:00:00 2001 From: Rohit Kumar Date: Tue, 20 Oct 2026 12:28:11 +0100 Subject: [PATCH 7/7] Python: match canonical attributes.json (attribute parity) The Python SDK now passes scripts/validate-attributes.sh. It was missing 127 canonical keys and defined 121 non-canonical ones. - Add the missing canonical constants with the Go/Node names (AGENTIC_*, CAUSE_*, SEVERITY_*, CORRELATION_*, CIRCUIT_BREAKER_*, ...). - Agentic tracer, scopes, orchestrations and guardrail recorder emit agenttel.agentic.* instead of top-level agent/step/task/... keys. GenAI reasoning/cached token counts use agenttel.genai.* keys. - Old constant names stay as deprecated aliases of the canonical keys. Never-emitted constants with no canonical equivalent are removed. - Keys still emitted that attributes.json lacks move to agenttel.extension_attributes. - Stop emitting duration_ms attributes that duplicate span duration; the invocation error message goes into the span status. - Fix AgentInvocation: exiting `with tracer.invoke()` raised "generator didn't stop", complete() left status "running", and fail() did not mark the invocation failed. Listed under Breaking in the 0.4.0-alpha CHANGELOG entry. Signed-off-by: Rohit Kumar --- CHANGELOG.md | 9 + .../src/agenttel/agentic/guardrail.py | 13 +- .../src/agenttel/agentic/orchestration.py | 16 +- .../src/agenttel/agentic/scopes.py | 156 ++++++----- .../src/agenttel/agentic/tracer.py | 22 +- .../src/agenttel/agentic_attributes.py | 242 ++++++++++++------ agenttel-python/src/agenttel/attributes.py | 162 +++++++----- .../src/agenttel/extension_attributes.py | 29 +++ .../src/agenttel/fastapi/decorators.py | 3 +- .../src/agenttel/genai/attributes.py | 37 ++- .../src/agenttel/genai/langchain.py | 2 - .../src/agenttel/genai/span_builder.py | 11 +- agenttel-python/src/agenttel/processor.py | 3 +- agenttel-python/tests/test_agentic.py | 48 ++++ agenttel-python/tests/test_attributes.py | 37 ++- 15 files changed, 499 insertions(+), 291 deletions(-) create mode 100644 agenttel-python/src/agenttel/extension_attributes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8272b84..a4effd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ All SDKs now accept the Spring Boot dialect: - **Fixed:** `ConsumptionPattern.ASYNC` value is `async` (was `async_`); added `EscalationLevel.AUTO_RESOLVE` (new default, as on the JVM). - **Fixed:** `examples/fastapi-example` imported the non-existent `BatchSpanExportProcessor` and used enum values the loader rejects. - `AgentTelMiddleware` is now a pure ASGI middleware; `auto_configure()` delegates to `instrument_fastapi()`. +- **Breaking (emitted attribute names):** the Python SDK now matches `attributes.json` (`scripts/validate-attributes.sh` passes for all three SDKs). Dashboards and queries on the old keys need updating: + - Agentic spans from `AgentTracer`, its scopes, the orchestrations and `GuardrailRecorder` use `agenttel.agentic.*`, as in Go and Node. Previously they used top-level namespaces: `agenttel.agent.name` became `agenttel.agentic.agent.name`, `agenttel.step.tool_name` became `agenttel.agentic.step.tool_name`, and likewise for `invocation`, `task`, `orchestration`, `handoff`, `cost`, `quality`, `guardrail`, `memory`, `human`, `code`, `eval`, `retrieval` and `conversation`. Renamed keys: `orchestration.stage_count` → `orchestration.total_stages`, `invocation.error_source` → `agentic.error.source`, `retrieval.relevance_score` → `retrieval.relevance_score_avg`, `conversation.role` → `conversation.speaker_role`, `agenttel.reranker.{input,output}_count` → `agenttel.agentic.reranker.{input,output}_documents`. The eval-loop iteration count is now `agenttel.agentic.step.iteration` (was `agenttel.quality.iterations`). + - GenAI spans: `gen_ai.usage.reasoning_tokens` → `agenttel.genai.reasoning_tokens`, `gen_ai.usage.cached_tokens` → `agenttel.genai.cached_read_tokens`. The duplicate `gen_ai.retrieval.document_count` on LangChain retriever spans is gone (`agenttel.genai.rag_source_count` carries the same value). + - No longer emitted, because the span already records them: `agenttel.invocation.duration_ms`, `agenttel.step.tool_duration_ms` and `agenttel.code.duration_ms` (the span's duration) and `agenttel.invocation.error_message` (now the span status description; `AgentInvocation.fail()` also sets the span status to `ERROR`). + - Old constant names stay as deprecated aliases that point at the canonical keys (e.g. `agentic_attributes.AGENT_NAME == AGENTIC_AGENT_NAME`, `attributes.ANOMALY_Z_SCORE == ANOMALY_LATENCY_Z_SCORE`, `genai.attributes.USAGE_CACHED_TOKENS == CACHED_READ_TOKENS`). New code should use the `AGENTIC_*` names, which match Go and Node. + - Removed constants that were never emitted and have no canonical equivalent: `attributes.DEPENDENCY_*`, `CONSUMER_*` (use `TOPOLOGY_DEPENDENCIES` / `TOPOLOGY_CONSUMERS`), `TOPOLOGY_SERVICE_VERSION`, `ANOMALY_BASELINE_MEAN/STDDEV`, `SLO_TYPE`, `SLO_ALERT_LEVEL`, `ERROR_IS_TRANSIENT`, `CAUSALITY_DEPENDENCY_CHAIN`, `DEPLOYMENT_ENVIRONMENT`, `GENAI_RAG_ENABLED`; `agentic_attributes.TASK_PRIORITY`, `MEMORY_QUERY`, `MEMORY_HIT`; `genai.attributes.REQUEST_STOP_SEQUENCES`, `REQUEST_FREQUENCY_PENALTY`, `REQUEST_PRESENCE_PENALTY`, `EMBEDDING_*`, `GUARDRAIL_ACTION`. + - Keys the Python SDK still emits but `attributes.json` does not define now live in `agenttel.extension_attributes`: `agenttel.baseline.latency_p95_ms`, `agenttel.operation.profile`, `gen_ai.usage.total_tokens`, `gen_ai.retrieval.source`, `agenttel.genai.rag_enabled`, plus agentic extras renamed into the agentic namespace (`agenttel.agentic.step.description`, `step.tool_input`, `step.tool_output`, `handoff.context_keys`, `human.feedback`, `code.output_size`, `eval.max_score`, `quality.confidence`, `guardrail.input_violation`, `guardrail.output_violation`). +- **Added:** the remaining canonical constants (`cause.*`, `severity.*`, `correlation.*`, `circuit_breaker.*`, `session.*`, `agent.id/role/session_id`, new `baseline.*`, `deployment.*`, `genai.*` and event names, and all `agenttel.agentic.*` keys), named as in Go and Node. +- **Fixed:** `with tracer.invoke(...)` raised `RuntimeError: generator didn't stop` on exit. `AgentInvocation.complete()` now sets `invocation.status` to `completed` (it stayed `running`), and `fail()` sets it to `failed`. ### Node.js (`@agenttel/node`) diff --git a/agenttel-python/src/agenttel/agentic/guardrail.py b/agenttel-python/src/agenttel/agentic/guardrail.py index 6f87301..71d94cd 100644 --- a/agenttel-python/src/agenttel/agentic/guardrail.py +++ b/agenttel-python/src/agenttel/agentic/guardrail.py @@ -10,6 +10,7 @@ from opentelemetry import trace from agenttel import agentic_attributes as aa +from agenttel import extension_attributes as ext from agenttel.enums import GuardrailAction @@ -59,13 +60,13 @@ def record( # Set span attributes if available if span and span.is_recording(): - span.set_attribute(aa.GUARDRAIL_TRIGGERED, True) - span.set_attribute(aa.GUARDRAIL_NAME, name) - span.set_attribute(aa.GUARDRAIL_ACTION, action.value) + span.set_attribute(aa.AGENTIC_GUARDRAIL_TRIGGERED, True) + span.set_attribute(aa.AGENTIC_GUARDRAIL_NAME, name) + span.set_attribute(aa.AGENTIC_GUARDRAIL_ACTION, action.value) if reason: - span.set_attribute(aa.GUARDRAIL_REASON, reason) - span.set_attribute(aa.GUARDRAIL_INPUT_VIOLATION, input_violation) - span.set_attribute(aa.GUARDRAIL_OUTPUT_VIOLATION, output_violation) + span.set_attribute(aa.AGENTIC_GUARDRAIL_REASON, reason) + span.set_attribute(ext.AGENTIC_GUARDRAIL_INPUT_VIOLATION, input_violation) + span.set_attribute(ext.AGENTIC_GUARDRAIL_OUTPUT_VIOLATION, output_violation) return event diff --git a/agenttel-python/src/agenttel/agentic/orchestration.py b/agenttel-python/src/agenttel/agentic/orchestration.py index b029c07..0d43664 100644 --- a/agenttel-python/src/agenttel/agentic/orchestration.py +++ b/agenttel-python/src/agenttel/agentic/orchestration.py @@ -23,8 +23,8 @@ def run(self, stages: list[tuple[str, Callable[[], Any]]]) -> list[Any]: f"orchestration.sequential {self._name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.ORCHESTRATION_PATTERN, OrchestrationPattern.SEQUENTIAL.value) - span.set_attribute(aa.ORCHESTRATION_STAGE_COUNT, len(stages)) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PATTERN, OrchestrationPattern.SEQUENTIAL.value) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_TOTAL_STAGES, len(stages)) results = [] try: @@ -34,7 +34,7 @@ def run(self, stages: list[tuple[str, Callable[[], Any]]]) -> list[Any]: f"stage {stage_name}", kind=trace.SpanKind.INTERNAL, ) - stage_span.set_attribute(aa.ORCHESTRATION_STAGE, stage_name) + stage_span.set_attribute(aa.AGENTIC_ORCHESTRATION_STAGE, stage_name) try: with trace.use_span(stage_span, end_on_exit=False): result = handler() @@ -72,8 +72,8 @@ def run(self, branches: list[tuple[str, Callable[[], Any]]]) -> list[Any]: f"orchestration.parallel {self._name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.ORCHESTRATION_PATTERN, OrchestrationPattern.PARALLEL.value) - span.set_attribute(aa.ORCHESTRATION_PARALLEL_BRANCHES, len(branches)) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PATTERN, OrchestrationPattern.PARALLEL.value) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PARALLEL_BRANCHES, len(branches)) results: list[Any] = [None] * len(branches) try: @@ -130,7 +130,7 @@ def run( f"orchestration.eval_loop {self._name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.ORCHESTRATION_PATTERN, OrchestrationPattern.EVALUATOR_OPTIMIZER.value) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PATTERN, OrchestrationPattern.EVALUATOR_OPTIMIZER.value) best_output = None best_score = 0.0 @@ -146,10 +146,10 @@ def run( best_output = output if score >= threshold: - span.set_attribute(aa.QUALITY_ITERATIONS, i + 1) + span.set_attribute(aa.AGENTIC_STEP_ITERATION, i + 1) return best_output, best_score, i + 1 - span.set_attribute(aa.QUALITY_ITERATIONS, self._max_iterations) + span.set_attribute(aa.AGENTIC_STEP_ITERATION, self._max_iterations) except Exception as exc: span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc))) span.record_exception(exc) diff --git a/agenttel-python/src/agenttel/agentic/scopes.py b/agenttel-python/src/agenttel/agentic/scopes.py index 8bcaa82..790bed6 100644 --- a/agenttel-python/src/agenttel/agentic/scopes.py +++ b/agenttel-python/src/agenttel/agentic/scopes.py @@ -9,6 +9,7 @@ from opentelemetry import trace from agenttel import agentic_attributes as aa +from agenttel import extension_attributes as ext from agenttel.enums import ( HumanCheckpointType, InvocationStatus, @@ -45,39 +46,37 @@ def __init__( self._agent_name = agent_name self._cost_aggregator = cost_aggregator self._step_count = 0 - self._start_time = time.time() self._status = InvocationStatus.RUNNING self._ctx_token = None def __enter__(self) -> AgentInvocation: - self._ctx_token = trace.use_span(self._span, end_on_exit=False).__enter__() - self._span.set_attribute(aa.INVOCATION_STATUS, InvocationStatus.RUNNING.value) + self._ctx_token = trace.use_span(self._span, end_on_exit=False) + self._ctx_token.__enter__() + self._span.set_attribute(aa.AGENTIC_INVOCATION_STATUS, InvocationStatus.RUNNING.value) return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - duration_ms = (time.time() - self._start_time) * 1000 - self._span.set_attribute(aa.INVOCATION_STEPS, self._step_count) - self._span.set_attribute(aa.INVOCATION_DURATION_MS, duration_ms) + self._span.set_attribute(aa.AGENTIC_INVOCATION_STEPS, self._step_count) if exc_type is not None: - self._span.set_attribute(aa.INVOCATION_STATUS, InvocationStatus.FAILED.value) - self._span.set_attribute(aa.INVOCATION_ERROR_MESSAGE, str(exc_val)) + self._span.set_attribute(aa.AGENTIC_INVOCATION_STATUS, InvocationStatus.FAILED.value) self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.record_exception(exc_val) elif self._status == InvocationStatus.RUNNING: - self._span.set_attribute(aa.INVOCATION_STATUS, InvocationStatus.COMPLETED.value) + self._span.set_attribute(aa.AGENTIC_INVOCATION_STATUS, InvocationStatus.COMPLETED.value) # Set cost attributes if available if self._cost_aggregator: summary = self._cost_aggregator.summary() - self._span.set_attribute(aa.COST_TOTAL_USD, summary.get("total_cost_usd", 0)) - self._span.set_attribute(aa.COST_INPUT_TOKENS, summary.get("total_input_tokens", 0)) - self._span.set_attribute(aa.COST_OUTPUT_TOKENS, summary.get("total_output_tokens", 0)) - self._span.set_attribute(aa.COST_LLM_CALLS, summary.get("llm_calls", 0)) + self._span.set_attribute(aa.AGENTIC_COST_TOTAL_USD, summary.get("total_cost_usd", 0)) + self._span.set_attribute(aa.AGENTIC_COST_INPUT_TOKENS, summary.get("total_input_tokens", 0)) + self._span.set_attribute(aa.AGENTIC_COST_OUTPUT_TOKENS, summary.get("total_output_tokens", 0)) + self._span.set_attribute(aa.AGENTIC_COST_LLM_CALLS, summary.get("llm_calls", 0)) self._span.end() if self._ctx_token: - trace.use_span(self._span, end_on_exit=False).__exit__(None, None, None) + self._ctx_token.__exit__(None, None, None) + self._ctx_token = None def step(self, step_type: StepType, description: str = "") -> StepScope: """Create a step within this invocation.""" @@ -86,11 +85,11 @@ def step(self, step_type: StepType, description: str = "") -> StepScope: f"agent.step.{step_type.value}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.STEP_NUMBER, self._step_count) - span.set_attribute(aa.STEP_TYPE, step_type.value) + span.set_attribute(aa.AGENTIC_STEP_NUMBER, self._step_count) + span.set_attribute(aa.AGENTIC_STEP_TYPE, step_type.value) if description: - span.set_attribute(aa.STEP_DESCRIPTION, description) - span.set_attribute(aa.INVOCATION_ID, self._invocation_id) + span.set_attribute(ext.AGENTIC_STEP_DESCRIPTION, description) + span.set_attribute(aa.AGENTIC_INVOCATION_ID, self._invocation_id) return StepScope(span=span) def tool_call(self, tool_name: str) -> ToolCallScope: @@ -100,10 +99,10 @@ def tool_call(self, tool_name: str) -> ToolCallScope: f"agent.tool_call {tool_name}", kind=trace.SpanKind.CLIENT, ) - span.set_attribute(aa.STEP_TOOL_NAME, tool_name) - span.set_attribute(aa.STEP_TYPE, StepType.ACTION.value) - span.set_attribute(aa.STEP_NUMBER, self._step_count) - span.set_attribute(aa.INVOCATION_ID, self._invocation_id) + span.set_attribute(aa.AGENTIC_STEP_TOOL_NAME, tool_name) + span.set_attribute(aa.AGENTIC_STEP_TYPE, StepType.ACTION.value) + span.set_attribute(aa.AGENTIC_STEP_NUMBER, self._step_count) + span.set_attribute(aa.AGENTIC_INVOCATION_ID, self._invocation_id) return ToolCallScope(span=span, tool_name=tool_name) def task(self, name: str, parent_id: str | None = None, depth: int = 0) -> TaskScope: @@ -113,12 +112,12 @@ def task(self, name: str, parent_id: str | None = None, depth: int = 0) -> TaskS f"agent.task {name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.TASK_ID, task_id) - span.set_attribute(aa.TASK_NAME, name) - span.set_attribute(aa.INVOCATION_ID, self._invocation_id) + span.set_attribute(aa.AGENTIC_TASK_ID, task_id) + span.set_attribute(aa.AGENTIC_TASK_NAME, name) + span.set_attribute(aa.AGENTIC_INVOCATION_ID, self._invocation_id) if parent_id: - span.set_attribute(aa.TASK_PARENT_ID, parent_id) - span.set_attribute(aa.TASK_DEPTH, depth) + span.set_attribute(aa.AGENTIC_TASK_PARENT_ID, parent_id) + span.set_attribute(aa.AGENTIC_TASK_DEPTH, depth) return TaskScope( span=span, tracer=self._tracer, @@ -132,10 +131,10 @@ def handoff(self, to_agent: str, reason: str = "") -> HandoffScope: f"agent.handoff {self._agent_name} -> {to_agent}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.HANDOFF_FROM_AGENT, self._agent_name) - span.set_attribute(aa.HANDOFF_TO_AGENT, to_agent) + span.set_attribute(aa.AGENTIC_HANDOFF_FROM_AGENT, self._agent_name) + span.set_attribute(aa.AGENTIC_HANDOFF_TO_AGENT, to_agent) if reason: - span.set_attribute(aa.HANDOFF_REASON, reason) + span.set_attribute(aa.AGENTIC_HANDOFF_REASON, reason) return HandoffScope(span=span) def human_checkpoint( @@ -146,7 +145,7 @@ def human_checkpoint( f"agent.human.{checkpoint_type.value}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.HUMAN_CHECKPOINT_TYPE, checkpoint_type.value) + span.set_attribute(aa.AGENTIC_HUMAN_CHECKPOINT_TYPE, checkpoint_type.value) return HumanCheckpointScope(span=span) def code_execution( @@ -157,8 +156,8 @@ def code_execution( f"agent.code.{language}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.CODE_LANGUAGE, language) - span.set_attribute(aa.CODE_SANDBOXED, sandboxed) + span.set_attribute(aa.AGENTIC_CODE_LANGUAGE, language) + span.set_attribute(aa.AGENTIC_CODE_SANDBOXED, sandboxed) return CodeExecutionScope(span=span) def evaluation(self, scorer_name: str, criteria: str = "") -> EvaluationScope: @@ -167,9 +166,9 @@ def evaluation(self, scorer_name: str, criteria: str = "") -> EvaluationScope: f"agent.eval {scorer_name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.EVAL_SCORER_NAME, scorer_name) + span.set_attribute(aa.AGENTIC_EVAL_SCORER_NAME, scorer_name) if criteria: - span.set_attribute(aa.EVAL_CRITERIA, criteria) + span.set_attribute(aa.AGENTIC_EVAL_CRITERIA, criteria) return EvaluationScope(span=span) def retriever(self, query: str, store_type: str = "") -> RetrieverScope: @@ -178,24 +177,25 @@ def retriever(self, query: str, store_type: str = "") -> RetrieverScope: "agent.retrieval", kind=trace.SpanKind.CLIENT, ) - span.set_attribute(aa.RETRIEVAL_QUERY, query) + span.set_attribute(aa.AGENTIC_RETRIEVAL_QUERY, query) if store_type: - span.set_attribute(aa.RETRIEVAL_STORE_TYPE, store_type) + span.set_attribute(aa.AGENTIC_RETRIEVAL_STORE_TYPE, store_type) return RetrieverScope(span=span) def complete(self, goal_achieved: bool = True, confidence: float | None = None) -> None: """Mark the invocation as completed.""" self._status = InvocationStatus.COMPLETED - self._span.set_attribute(aa.QUALITY_GOAL_ACHIEVED, goal_achieved) + self._span.set_attribute(aa.AGENTIC_INVOCATION_STATUS, InvocationStatus.COMPLETED.value) + self._span.set_attribute(aa.AGENTIC_QUALITY_GOAL_ACHIEVED, goal_achieved) if confidence is not None: - self._span.set_attribute(aa.QUALITY_CONFIDENCE, confidence) + self._span.set_attribute(ext.AGENTIC_QUALITY_CONFIDENCE, confidence) def fail(self, error_source: str = "agent", error_message: str = "") -> None: """Mark the invocation as failed.""" self._status = InvocationStatus.FAILED - self._span.set_attribute(aa.INVOCATION_ERROR_SOURCE, error_source) - if error_message: - self._span.set_attribute(aa.INVOCATION_ERROR_MESSAGE, error_message) + self._span.set_attribute(aa.AGENTIC_INVOCATION_STATUS, InvocationStatus.FAILED.value) + self._span.set_attribute(aa.AGENTIC_ERROR_SOURCE, error_source) + self._span.set_status(trace.Status(trace.StatusCode.ERROR, error_message or None)) class StepScope: @@ -220,28 +220,25 @@ class ToolCallScope: def __init__(self, span: trace.Span, tool_name: str) -> None: self._span = span self._tool_name = tool_name - self._start_time = time.time() def __enter__(self) -> ToolCallScope: return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - duration_ms = (time.time() - self._start_time) * 1000 - self._span.set_attribute(aa.STEP_TOOL_DURATION_MS, duration_ms) if exc_type: - self._span.set_attribute(aa.STEP_TOOL_STATUS, "error") + self._span.set_attribute(aa.AGENTIC_STEP_TOOL_STATUS, "error") self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.record_exception(exc_val) else: - self._span.set_attribute(aa.STEP_TOOL_STATUS, "success") + self._span.set_attribute(aa.AGENTIC_STEP_TOOL_STATUS, "success") self._span.end() def set_input(self, input_data: str) -> None: - self._span.set_attribute(aa.STEP_TOOL_INPUT, input_data) + self._span.set_attribute(ext.AGENTIC_STEP_TOOL_INPUT, input_data) def set_result(self, result: Any) -> None: - self._span.set_attribute(aa.STEP_TOOL_OUTPUT, str(result)[:4096]) - self._span.set_attribute(aa.STEP_TOOL_STATUS, "success") + self._span.set_attribute(ext.AGENTIC_STEP_TOOL_OUTPUT, str(result)[:4096]) + self._span.set_attribute(aa.AGENTIC_STEP_TOOL_STATUS, "success") class TaskScope: @@ -265,11 +262,11 @@ def __enter__(self) -> TaskScope: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: if exc_type: - self._span.set_attribute(aa.TASK_STATUS, "failed") + self._span.set_attribute(aa.AGENTIC_TASK_STATUS, "failed") self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.record_exception(exc_val) else: - self._span.set_attribute(aa.TASK_STATUS, "completed") + self._span.set_attribute(aa.AGENTIC_TASK_STATUS, "completed") self._span.end() def step(self, step_type: StepType, description: str = "") -> StepScope: @@ -278,11 +275,11 @@ def step(self, step_type: StepType, description: str = "") -> StepScope: f"agent.step.{step_type.value}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.STEP_NUMBER, self._step_count) - span.set_attribute(aa.STEP_TYPE, step_type.value) + span.set_attribute(aa.AGENTIC_STEP_NUMBER, self._step_count) + span.set_attribute(aa.AGENTIC_STEP_TYPE, step_type.value) if description: - span.set_attribute(aa.STEP_DESCRIPTION, description) - span.set_attribute(aa.TASK_ID, self._task_id) + span.set_attribute(ext.AGENTIC_STEP_DESCRIPTION, description) + span.set_attribute(aa.AGENTIC_TASK_ID, self._task_id) return StepScope(span=span) @property @@ -306,10 +303,10 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self._span.end() def set_chain_depth(self, depth: int) -> None: - self._span.set_attribute(aa.HANDOFF_CHAIN_DEPTH, depth) + self._span.set_attribute(aa.AGENTIC_HANDOFF_CHAIN_DEPTH, depth) def set_context_keys(self, keys: list[str]) -> None: - self._span.set_attribute(aa.HANDOFF_CONTEXT_KEYS, keys) + self._span.set_attribute(ext.AGENTIC_HANDOFF_CONTEXT_KEYS, keys) class HumanCheckpointScope: @@ -324,16 +321,16 @@ def __enter__(self) -> HumanCheckpointScope: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: wait_ms = (time.time() - self._start_time) * 1000 - self._span.set_attribute(aa.HUMAN_WAIT_MS, wait_ms) + self._span.set_attribute(aa.AGENTIC_HUMAN_WAIT_MS, wait_ms) if exc_type: self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.end() def set_decision(self, decision: str) -> None: - self._span.set_attribute(aa.HUMAN_DECISION, decision) + self._span.set_attribute(aa.AGENTIC_HUMAN_DECISION, decision) def set_feedback(self, feedback: str) -> None: - self._span.set_attribute(aa.HUMAN_FEEDBACK, feedback) + self._span.set_attribute(ext.AGENTIC_HUMAN_FEEDBACK, feedback) class CodeExecutionScope: @@ -341,24 +338,21 @@ class CodeExecutionScope: def __init__(self, span: trace.Span) -> None: self._span = span - self._start_time = time.time() def __enter__(self) -> CodeExecutionScope: return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - duration_ms = (time.time() - self._start_time) * 1000 - self._span.set_attribute(aa.CODE_DURATION_MS, duration_ms) if exc_type: - self._span.set_attribute(aa.CODE_STATUS, "error") + self._span.set_attribute(aa.AGENTIC_CODE_STATUS, "error") self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.end() def set_result(self, exit_code: int, output_size: int = 0) -> None: - self._span.set_attribute(aa.CODE_EXIT_CODE, exit_code) - self._span.set_attribute(aa.CODE_STATUS, "success" if exit_code == 0 else "error") + self._span.set_attribute(aa.AGENTIC_CODE_EXIT_CODE, exit_code) + self._span.set_attribute(aa.AGENTIC_CODE_STATUS, "success" if exit_code == 0 else "error") if output_size: - self._span.set_attribute(aa.CODE_OUTPUT_SIZE, output_size) + self._span.set_attribute(ext.AGENTIC_CODE_OUTPUT_SIZE, output_size) class EvaluationScope: @@ -376,14 +370,14 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self._span.end() def set_score(self, score: float, max_score: float = 1.0) -> None: - self._span.set_attribute(aa.EVAL_SCORE, score) - self._span.set_attribute(aa.EVAL_MAX_SCORE, max_score) + self._span.set_attribute(aa.AGENTIC_EVAL_SCORE, score) + self._span.set_attribute(ext.AGENTIC_EVAL_MAX_SCORE, max_score) def set_feedback(self, feedback: str) -> None: - self._span.set_attribute(aa.EVAL_FEEDBACK, feedback) + self._span.set_attribute(aa.AGENTIC_EVAL_FEEDBACK, feedback) def set_type(self, eval_type: str) -> None: - self._span.set_attribute(aa.EVAL_TYPE, eval_type) + self._span.set_attribute(aa.AGENTIC_EVAL_TYPE, eval_type) class RetrieverScope: @@ -401,9 +395,9 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self._span.end() def set_results(self, document_count: int, relevance_score: float = 0.0) -> None: - self._span.set_attribute(aa.RETRIEVAL_DOCUMENT_COUNT, document_count) + self._span.set_attribute(aa.AGENTIC_RETRIEVAL_DOCUMENT_COUNT, document_count) if relevance_score > 0: - self._span.set_attribute(aa.RETRIEVAL_RELEVANCE_SCORE, relevance_score) + self._span.set_attribute(aa.AGENTIC_RETRIEVAL_RELEVANCE_SCORE_AVG, relevance_score) class RerankerScope: @@ -421,8 +415,8 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self._span.end() def set_results(self, input_count: int, output_count: int) -> None: - self._span.set_attribute("agenttel.reranker.input_count", input_count) - self._span.set_attribute("agenttel.reranker.output_count", output_count) + self._span.set_attribute(aa.AGENTIC_RERANKER_INPUT_DOCUMENTS, input_count) + self._span.set_attribute(aa.AGENTIC_RERANKER_OUTPUT_DOCUMENTS, output_count) class Orchestration: @@ -443,7 +437,7 @@ def __enter__(self) -> Orchestration: return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - self._span.set_attribute(aa.ORCHESTRATION_STAGE_COUNT, self._stage_count) + self._span.set_attribute(aa.AGENTIC_ORCHESTRATION_TOTAL_STAGES, self._stage_count) if exc_type: self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc_val))) self._span.record_exception(exc_val) @@ -456,12 +450,12 @@ def stage(self, name: str) -> StepScope: f"agent.stage {name}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.ORCHESTRATION_STAGE, name) - span.set_attribute(aa.ORCHESTRATION_PATTERN, self._pattern.value) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_STAGE, name) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PATTERN, self._pattern.value) return StepScope(span=span) def set_coordinator_id(self, coordinator_id: str) -> None: - self._span.set_attribute(aa.ORCHESTRATION_COORDINATOR_ID, coordinator_id) + self._span.set_attribute(aa.AGENTIC_ORCHESTRATION_COORDINATOR_ID, coordinator_id) def set_parallel_branches(self, count: int) -> None: - self._span.set_attribute(aa.ORCHESTRATION_PARALLEL_BRANCHES, count) + self._span.set_attribute(aa.AGENTIC_ORCHESTRATION_PARALLEL_BRANCHES, count) diff --git a/agenttel-python/src/agenttel/agentic/tracer.py b/agenttel-python/src/agenttel/agentic/tracer.py index 8882ce8..4d827bf 100644 --- a/agenttel-python/src/agenttel/agentic/tracer.py +++ b/agenttel-python/src/agenttel/agentic/tracer.py @@ -62,16 +62,16 @@ def invoke(self, goal: str) -> AgentInvocation: ) # Set agent identity attributes - span.set_attribute(aa.AGENT_NAME, self._name) - span.set_attribute(aa.AGENT_TYPE, self._type.value) + span.set_attribute(aa.AGENTIC_AGENT_NAME, self._name) + span.set_attribute(aa.AGENTIC_AGENT_TYPE, self._type.value) if self._framework: - span.set_attribute(aa.AGENT_FRAMEWORK, self._framework) + span.set_attribute(aa.AGENTIC_AGENT_FRAMEWORK, self._framework) if self._version: - span.set_attribute(aa.AGENT_VERSION, self._version) + span.set_attribute(aa.AGENTIC_AGENT_VERSION, self._version) # Set invocation attributes - span.set_attribute(aa.INVOCATION_ID, invocation_id) - span.set_attribute(aa.INVOCATION_GOAL, goal) + span.set_attribute(aa.AGENTIC_INVOCATION_ID, invocation_id) + span.set_attribute(aa.AGENTIC_INVOCATION_GOAL, goal) return AgentInvocation( span=span, @@ -95,8 +95,8 @@ def orchestrate(self, pattern: OrchestrationPattern) -> Orchestration: f"agent.orchestrate {pattern.value}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.ORCHESTRATION_PATTERN, pattern.value) - span.set_attribute(aa.AGENT_NAME, self._name) + span.set_attribute(aa.AGENTIC_ORCHESTRATION_PATTERN, pattern.value) + span.set_attribute(aa.AGENTIC_AGENT_NAME, self._name) return Orchestration( span=span, @@ -115,9 +115,9 @@ def memory( f"agent.memory.{operation}", kind=trace.SpanKind.INTERNAL, ) - span.set_attribute(aa.MEMORY_OPERATION, operation) - span.set_attribute(aa.MEMORY_STORE_TYPE, store_type) - span.set_attribute(aa.MEMORY_ITEMS, items) + span.set_attribute(aa.AGENTIC_MEMORY_OPERATION, operation) + span.set_attribute(aa.AGENTIC_MEMORY_STORE_TYPE, store_type) + span.set_attribute(aa.AGENTIC_MEMORY_ITEMS, items) return span @property diff --git a/agenttel-python/src/agenttel/agentic_attributes.py b/agenttel-python/src/agenttel/agentic_attributes.py index f9525ca..eb28ea3 100644 --- a/agenttel-python/src/agenttel/agentic_attributes.py +++ b/agenttel-python/src/agenttel/agentic_attributes.py @@ -1,112 +1,188 @@ -"""Agentic observability semantic attributes (70+ attributes).""" +"""Agentic observability semantic attributes (``agenttel.agentic.*``). + +Values match the canonical ``attributes.json`` at the repository root. +SDK-specific keys that are not in the canonical schema live in +:mod:`agenttel.extension_attributes`. +""" # Agent Identity -AGENT_NAME = "agenttel.agent.name" -AGENT_TYPE = "agenttel.agent.type" -AGENT_FRAMEWORK = "agenttel.agent.framework" -AGENT_VERSION = "agenttel.agent.version" +AGENTIC_AGENT_NAME = "agenttel.agentic.agent.name" +AGENTIC_AGENT_TYPE = "agenttel.agentic.agent.type" +AGENTIC_AGENT_FRAMEWORK = "agenttel.agentic.agent.framework" +AGENTIC_AGENT_VERSION = "agenttel.agentic.agent.version" # Invocation -INVOCATION_ID = "agenttel.invocation.id" -INVOCATION_GOAL = "agenttel.invocation.goal" -INVOCATION_STATUS = "agenttel.invocation.status" -INVOCATION_STEPS = "agenttel.invocation.steps" -INVOCATION_DURATION_MS = "agenttel.invocation.duration_ms" -INVOCATION_ERROR_SOURCE = "agenttel.invocation.error_source" -INVOCATION_ERROR_MESSAGE = "agenttel.invocation.error_message" +AGENTIC_INVOCATION_ID = "agenttel.agentic.invocation.id" +AGENTIC_INVOCATION_GOAL = "agenttel.agentic.invocation.goal" +AGENTIC_INVOCATION_STATUS = "agenttel.agentic.invocation.status" +AGENTIC_INVOCATION_STEPS = "agenttel.agentic.invocation.steps" +AGENTIC_INVOCATION_MAX_STEPS = "agenttel.agentic.invocation.max_steps" # Task -TASK_ID = "agenttel.task.id" -TASK_NAME = "agenttel.task.name" -TASK_STATUS = "agenttel.task.status" -TASK_PARENT_ID = "agenttel.task.parent_id" -TASK_DEPTH = "agenttel.task.depth" -TASK_PRIORITY = "agenttel.task.priority" +AGENTIC_TASK_ID = "agenttel.agentic.task.id" +AGENTIC_TASK_NAME = "agenttel.agentic.task.name" +AGENTIC_TASK_STATUS = "agenttel.agentic.task.status" +AGENTIC_TASK_PARENT_ID = "agenttel.agentic.task.parent_id" +AGENTIC_TASK_DEPTH = "agenttel.agentic.task.depth" # Step -STEP_NUMBER = "agenttel.step.number" -STEP_TYPE = "agenttel.step.type" -STEP_ITERATION = "agenttel.step.iteration" -STEP_DESCRIPTION = "agenttel.step.description" -STEP_TOOL_NAME = "agenttel.step.tool_name" -STEP_TOOL_STATUS = "agenttel.step.tool_status" -STEP_TOOL_INPUT = "agenttel.step.tool_input" -STEP_TOOL_OUTPUT = "agenttel.step.tool_output" -STEP_TOOL_DURATION_MS = "agenttel.step.tool_duration_ms" +AGENTIC_STEP_NUMBER = "agenttel.agentic.step.number" +AGENTIC_STEP_TYPE = "agenttel.agentic.step.type" +AGENTIC_STEP_ITERATION = "agenttel.agentic.step.iteration" +AGENTIC_STEP_TOOL_NAME = "agenttel.agentic.step.tool_name" +AGENTIC_STEP_TOOL_STATUS = "agenttel.agentic.step.tool_status" # Orchestration -ORCHESTRATION_PATTERN = "agenttel.orchestration.pattern" -ORCHESTRATION_COORDINATOR_ID = "agenttel.orchestration.coordinator_id" -ORCHESTRATION_STAGE = "agenttel.orchestration.stage" -ORCHESTRATION_STAGE_COUNT = "agenttel.orchestration.stage_count" -ORCHESTRATION_PARALLEL_BRANCHES = "agenttel.orchestration.parallel_branches" +AGENTIC_ORCHESTRATION_PATTERN = "agenttel.agentic.orchestration.pattern" +AGENTIC_ORCHESTRATION_COORDINATOR_ID = "agenttel.agentic.orchestration.coordinator_id" +AGENTIC_ORCHESTRATION_STAGE = "agenttel.agentic.orchestration.stage" +AGENTIC_ORCHESTRATION_TOTAL_STAGES = "agenttel.agentic.orchestration.total_stages" +AGENTIC_ORCHESTRATION_PARALLEL_BRANCHES = "agenttel.agentic.orchestration.parallel_branches" +AGENTIC_ORCHESTRATION_AGGREGATION = "agenttel.agentic.orchestration.aggregation" # Handoff -HANDOFF_FROM_AGENT = "agenttel.handoff.from_agent" -HANDOFF_TO_AGENT = "agenttel.handoff.to_agent" -HANDOFF_REASON = "agenttel.handoff.reason" -HANDOFF_CHAIN_DEPTH = "agenttel.handoff.chain_depth" -HANDOFF_CONTEXT_KEYS = "agenttel.handoff.context_keys" +AGENTIC_HANDOFF_FROM_AGENT = "agenttel.agentic.handoff.from_agent" +AGENTIC_HANDOFF_TO_AGENT = "agenttel.agentic.handoff.to_agent" +AGENTIC_HANDOFF_REASON = "agenttel.agentic.handoff.reason" +AGENTIC_HANDOFF_CHAIN_DEPTH = "agenttel.agentic.handoff.chain_depth" # Cost -COST_TOTAL_USD = "agenttel.cost.total_usd" -COST_INPUT_TOKENS = "agenttel.cost.input_tokens" -COST_OUTPUT_TOKENS = "agenttel.cost.output_tokens" -COST_REASONING_TOKENS = "agenttel.cost.reasoning_tokens" -COST_LLM_CALLS = "agenttel.cost.llm_calls" +AGENTIC_COST_TOTAL_USD = "agenttel.agentic.cost.total_usd" +AGENTIC_COST_INPUT_TOKENS = "agenttel.agentic.cost.input_tokens" +AGENTIC_COST_OUTPUT_TOKENS = "agenttel.agentic.cost.output_tokens" +AGENTIC_COST_LLM_CALLS = "agenttel.agentic.cost.llm_calls" +AGENTIC_COST_REASONING_TOKENS = "agenttel.agentic.cost.reasoning_tokens" +AGENTIC_COST_CACHED_READ_TOKENS = "agenttel.agentic.cost.cached_read_tokens" +AGENTIC_COST_CACHED_WRITE_TOKENS = "agenttel.agentic.cost.cached_write_tokens" # Quality -QUALITY_GOAL_ACHIEVED = "agenttel.quality.goal_achieved" -QUALITY_HUMAN_INTERVENTIONS = "agenttel.quality.human_interventions" -QUALITY_LOOP_DETECTED = "agenttel.quality.loop_detected" -QUALITY_CONFIDENCE = "agenttel.quality.confidence" -QUALITY_ITERATIONS = "agenttel.quality.iterations" +AGENTIC_QUALITY_GOAL_ACHIEVED = "agenttel.agentic.quality.goal_achieved" +AGENTIC_QUALITY_HUMAN_INTERVENTIONS = "agenttel.agentic.quality.human_interventions" +AGENTIC_QUALITY_LOOP_DETECTED = "agenttel.agentic.quality.loop_detected" +AGENTIC_QUALITY_LOOP_ITERATIONS = "agenttel.agentic.quality.loop_iterations" +AGENTIC_QUALITY_EVAL_SCORE = "agenttel.agentic.quality.eval_score" # Guardrail -GUARDRAIL_TRIGGERED = "agenttel.guardrail.triggered" -GUARDRAIL_NAME = "agenttel.guardrail.name" -GUARDRAIL_ACTION = "agenttel.guardrail.action" -GUARDRAIL_REASON = "agenttel.guardrail.reason" -GUARDRAIL_INPUT_VIOLATION = "agenttel.guardrail.input_violation" -GUARDRAIL_OUTPUT_VIOLATION = "agenttel.guardrail.output_violation" +AGENTIC_GUARDRAIL_TRIGGERED = "agenttel.agentic.guardrail.triggered" +AGENTIC_GUARDRAIL_NAME = "agenttel.agentic.guardrail.name" +AGENTIC_GUARDRAIL_ACTION = "agenttel.agentic.guardrail.action" +AGENTIC_GUARDRAIL_REASON = "agenttel.agentic.guardrail.reason" # Memory -MEMORY_OPERATION = "agenttel.memory.operation" -MEMORY_STORE_TYPE = "agenttel.memory.store_type" -MEMORY_ITEMS = "agenttel.memory.items" -MEMORY_QUERY = "agenttel.memory.query" -MEMORY_HIT = "agenttel.memory.hit" +AGENTIC_MEMORY_OPERATION = "agenttel.agentic.memory.operation" +AGENTIC_MEMORY_STORE_TYPE = "agenttel.agentic.memory.store_type" +AGENTIC_MEMORY_ITEMS = "agenttel.agentic.memory.items" # Human-in-the-Loop -HUMAN_CHECKPOINT_TYPE = "agenttel.human.checkpoint_type" -HUMAN_WAIT_MS = "agenttel.human.wait_ms" -HUMAN_DECISION = "agenttel.human.decision" -HUMAN_FEEDBACK = "agenttel.human.feedback" +AGENTIC_HUMAN_CHECKPOINT_TYPE = "agenttel.agentic.human.checkpoint_type" +AGENTIC_HUMAN_WAIT_MS = "agenttel.agentic.human.wait_ms" +AGENTIC_HUMAN_DECISION = "agenttel.agentic.human.decision" # Code Execution -CODE_LANGUAGE = "agenttel.code.language" -CODE_STATUS = "agenttel.code.status" -CODE_EXIT_CODE = "agenttel.code.exit_code" -CODE_SANDBOXED = "agenttel.code.sandboxed" -CODE_DURATION_MS = "agenttel.code.duration_ms" -CODE_OUTPUT_SIZE = "agenttel.code.output_size" +AGENTIC_CODE_LANGUAGE = "agenttel.agentic.code.language" +AGENTIC_CODE_STATUS = "agenttel.agentic.code.status" +AGENTIC_CODE_EXIT_CODE = "agenttel.agentic.code.exit_code" +AGENTIC_CODE_SANDBOXED = "agenttel.agentic.code.sandboxed" # Evaluation -EVAL_SCORER_NAME = "agenttel.eval.scorer_name" -EVAL_CRITERIA = "agenttel.eval.criteria" -EVAL_SCORE = "agenttel.eval.score" -EVAL_FEEDBACK = "agenttel.eval.feedback" -EVAL_TYPE = "agenttel.eval.type" -EVAL_MAX_SCORE = "agenttel.eval.max_score" +AGENTIC_EVAL_SCORER_NAME = "agenttel.agentic.eval.scorer_name" +AGENTIC_EVAL_CRITERIA = "agenttel.agentic.eval.criteria" +AGENTIC_EVAL_SCORE = "agenttel.agentic.eval.score" +AGENTIC_EVAL_FEEDBACK = "agenttel.agentic.eval.feedback" +AGENTIC_EVAL_TYPE = "agenttel.agentic.eval.type" + +# Conversation +AGENTIC_CONVERSATION_ID = "agenttel.agentic.conversation.id" +AGENTIC_CONVERSATION_TURN = "agenttel.agentic.conversation.turn" +AGENTIC_CONVERSATION_MESSAGE_COUNT = "agenttel.agentic.conversation.message_count" +AGENTIC_CONVERSATION_SPEAKER_ROLE = "agenttel.agentic.conversation.speaker_role" # Retrieval -RETRIEVAL_QUERY = "agenttel.retrieval.query" -RETRIEVAL_STORE_TYPE = "agenttel.retrieval.store_type" -RETRIEVAL_DOCUMENT_COUNT = "agenttel.retrieval.document_count" -RETRIEVAL_RELEVANCE_SCORE = "agenttel.retrieval.relevance_score" +AGENTIC_RETRIEVAL_QUERY = "agenttel.agentic.retrieval.query" +AGENTIC_RETRIEVAL_STORE_TYPE = "agenttel.agentic.retrieval.store_type" +AGENTIC_RETRIEVAL_DOCUMENT_COUNT = "agenttel.agentic.retrieval.document_count" +AGENTIC_RETRIEVAL_TOP_K = "agenttel.agentic.retrieval.top_k" +AGENTIC_RETRIEVAL_RELEVANCE_SCORE_AVG = "agenttel.agentic.retrieval.relevance_score_avg" +AGENTIC_RETRIEVAL_RELEVANCE_SCORE_MIN = "agenttel.agentic.retrieval.relevance_score_min" -# Conversation -CONVERSATION_ID = "agenttel.conversation.id" -CONVERSATION_TURN = "agenttel.conversation.turn" -CONVERSATION_MESSAGE_COUNT = "agenttel.conversation.message_count" -CONVERSATION_ROLE = "agenttel.conversation.role" +# Reranker +AGENTIC_RERANKER_MODEL = "agenttel.agentic.reranker.model" +AGENTIC_RERANKER_INPUT_DOCUMENTS = "agenttel.agentic.reranker.input_documents" +AGENTIC_RERANKER_OUTPUT_DOCUMENTS = "agenttel.agentic.reranker.output_documents" +AGENTIC_RERANKER_TOP_SCORE = "agenttel.agentic.reranker.top_score" + +# Error +AGENTIC_ERROR_SOURCE = "agenttel.agentic.error.source" +AGENTIC_ERROR_CATEGORY = "agenttel.agentic.error.category" +AGENTIC_ERROR_RETRYABLE = "agenttel.agentic.error.retryable" + +# Capability +AGENTIC_CAPABILITY_TOOL_COUNT = "agenttel.agentic.capability.tool_count" +AGENTIC_CAPABILITY_SYSTEM_PROMPT_HASH = "agenttel.agentic.capability.system_prompt_hash" + +# Deprecated aliases (pre-0.4.0 names). Values now point at the canonical +# agenttel.agentic.* keys; the old top-level keys (agenttel.agent.name, +# agenttel.step.*, ...) are no longer emitted. +AGENT_NAME = AGENTIC_AGENT_NAME +AGENT_TYPE = AGENTIC_AGENT_TYPE +AGENT_FRAMEWORK = AGENTIC_AGENT_FRAMEWORK +AGENT_VERSION = AGENTIC_AGENT_VERSION +INVOCATION_ID = AGENTIC_INVOCATION_ID +INVOCATION_GOAL = AGENTIC_INVOCATION_GOAL +INVOCATION_STATUS = AGENTIC_INVOCATION_STATUS +INVOCATION_STEPS = AGENTIC_INVOCATION_STEPS +INVOCATION_ERROR_SOURCE = AGENTIC_ERROR_SOURCE +TASK_ID = AGENTIC_TASK_ID +TASK_NAME = AGENTIC_TASK_NAME +TASK_STATUS = AGENTIC_TASK_STATUS +TASK_PARENT_ID = AGENTIC_TASK_PARENT_ID +TASK_DEPTH = AGENTIC_TASK_DEPTH +STEP_NUMBER = AGENTIC_STEP_NUMBER +STEP_TYPE = AGENTIC_STEP_TYPE +STEP_ITERATION = AGENTIC_STEP_ITERATION +STEP_TOOL_NAME = AGENTIC_STEP_TOOL_NAME +STEP_TOOL_STATUS = AGENTIC_STEP_TOOL_STATUS +ORCHESTRATION_PATTERN = AGENTIC_ORCHESTRATION_PATTERN +ORCHESTRATION_COORDINATOR_ID = AGENTIC_ORCHESTRATION_COORDINATOR_ID +ORCHESTRATION_STAGE = AGENTIC_ORCHESTRATION_STAGE +ORCHESTRATION_STAGE_COUNT = AGENTIC_ORCHESTRATION_TOTAL_STAGES +ORCHESTRATION_PARALLEL_BRANCHES = AGENTIC_ORCHESTRATION_PARALLEL_BRANCHES +HANDOFF_FROM_AGENT = AGENTIC_HANDOFF_FROM_AGENT +HANDOFF_TO_AGENT = AGENTIC_HANDOFF_TO_AGENT +HANDOFF_REASON = AGENTIC_HANDOFF_REASON +HANDOFF_CHAIN_DEPTH = AGENTIC_HANDOFF_CHAIN_DEPTH +COST_TOTAL_USD = AGENTIC_COST_TOTAL_USD +COST_INPUT_TOKENS = AGENTIC_COST_INPUT_TOKENS +COST_OUTPUT_TOKENS = AGENTIC_COST_OUTPUT_TOKENS +COST_REASONING_TOKENS = AGENTIC_COST_REASONING_TOKENS +COST_LLM_CALLS = AGENTIC_COST_LLM_CALLS +QUALITY_GOAL_ACHIEVED = AGENTIC_QUALITY_GOAL_ACHIEVED +QUALITY_HUMAN_INTERVENTIONS = AGENTIC_QUALITY_HUMAN_INTERVENTIONS +QUALITY_LOOP_DETECTED = AGENTIC_QUALITY_LOOP_DETECTED +GUARDRAIL_TRIGGERED = AGENTIC_GUARDRAIL_TRIGGERED +GUARDRAIL_NAME = AGENTIC_GUARDRAIL_NAME +GUARDRAIL_ACTION = AGENTIC_GUARDRAIL_ACTION +GUARDRAIL_REASON = AGENTIC_GUARDRAIL_REASON +MEMORY_OPERATION = AGENTIC_MEMORY_OPERATION +MEMORY_STORE_TYPE = AGENTIC_MEMORY_STORE_TYPE +MEMORY_ITEMS = AGENTIC_MEMORY_ITEMS +HUMAN_CHECKPOINT_TYPE = AGENTIC_HUMAN_CHECKPOINT_TYPE +HUMAN_WAIT_MS = AGENTIC_HUMAN_WAIT_MS +HUMAN_DECISION = AGENTIC_HUMAN_DECISION +CODE_LANGUAGE = AGENTIC_CODE_LANGUAGE +CODE_STATUS = AGENTIC_CODE_STATUS +CODE_EXIT_CODE = AGENTIC_CODE_EXIT_CODE +CODE_SANDBOXED = AGENTIC_CODE_SANDBOXED +EVAL_SCORER_NAME = AGENTIC_EVAL_SCORER_NAME +EVAL_CRITERIA = AGENTIC_EVAL_CRITERIA +EVAL_SCORE = AGENTIC_EVAL_SCORE +EVAL_FEEDBACK = AGENTIC_EVAL_FEEDBACK +EVAL_TYPE = AGENTIC_EVAL_TYPE +RETRIEVAL_QUERY = AGENTIC_RETRIEVAL_QUERY +RETRIEVAL_STORE_TYPE = AGENTIC_RETRIEVAL_STORE_TYPE +RETRIEVAL_DOCUMENT_COUNT = AGENTIC_RETRIEVAL_DOCUMENT_COUNT +RETRIEVAL_RELEVANCE_SCORE = AGENTIC_RETRIEVAL_RELEVANCE_SCORE_AVG +CONVERSATION_ID = AGENTIC_CONVERSATION_ID +CONVERSATION_TURN = AGENTIC_CONVERSATION_TURN +CONVERSATION_MESSAGE_COUNT = AGENTIC_CONVERSATION_MESSAGE_COUNT +CONVERSATION_ROLE = AGENTIC_CONVERSATION_SPEAKER_ROLE diff --git a/agenttel-python/src/agenttel/attributes.py b/agenttel-python/src/agenttel/attributes.py index d752b46..dea002f 100644 --- a/agenttel-python/src/agenttel/attributes.py +++ b/agenttel-python/src/agenttel/attributes.py @@ -1,4 +1,9 @@ -"""AgentTel semantic attributes for OpenTelemetry spans.""" +"""AgentTel semantic attributes for OpenTelemetry spans. + +Values match the canonical ``attributes.json`` at the repository root. +SDK-specific keys that are not in the canonical schema live in +:mod:`agenttel.extension_attributes`. +""" # Topology attributes TOPOLOGY_TEAM = "agenttel.topology.team" @@ -7,89 +12,124 @@ TOPOLOGY_ON_CALL_CHANNEL = "agenttel.topology.on_call_channel" TOPOLOGY_ONCALL_CHANNEL = TOPOLOGY_ON_CALL_CHANNEL # deprecated alias TOPOLOGY_REPO_URL = "agenttel.topology.repo_url" -TOPOLOGY_SERVICE_VERSION = "agenttel.topology.service_version" - -# Dependency attributes -DEPENDENCY_NAME = "agenttel.dependency.name" -DEPENDENCY_TYPE = "agenttel.dependency.type" -DEPENDENCY_CRITICALITY = "agenttel.dependency.criticality" -DEPENDENCY_PROTOCOL = "agenttel.dependency.protocol" -DEPENDENCY_TIMEOUT_MS = "agenttel.dependency.timeout_ms" -DEPENDENCY_CIRCUIT_BREAKER = "agenttel.dependency.circuit_breaker" -DEPENDENCY_FALLBACK = "agenttel.dependency.fallback" -DEPENDENCY_HEALTH_ENDPOINT = "agenttel.dependency.health_endpoint" -DEPENDENCY_STATE = "agenttel.dependency.state" - -# Consumer attributes -CONSUMER_NAME = "agenttel.consumer.name" -CONSUMER_PATTERN = "agenttel.consumer.pattern" -CONSUMER_SLA_LATENCY_MS = "agenttel.consumer.sla_latency_ms" +TOPOLOGY_DEPENDENCIES = "agenttel.topology.dependencies" +TOPOLOGY_CONSUMERS = "agenttel.topology.consumers" + +# Baseline attributes +BASELINE_LATENCY_P50_MS = "agenttel.baseline.latency_p50_ms" +BASELINE_LATENCY_P99_MS = "agenttel.baseline.latency_p99_ms" +BASELINE_ERROR_RATE = "agenttel.baseline.error_rate" +BASELINE_THROUGHPUT_RPS = "agenttel.baseline.throughput_rps" +BASELINE_SOURCE = "agenttel.baseline.source" +BASELINE_UPDATED_AT = "agenttel.baseline.updated_at" +BASELINE_SLO = "agenttel.baseline.slo" +BASELINE_SAMPLE_COUNT = "agenttel.baseline.sample_count" +BASELINE_CONFIDENCE = "agenttel.baseline.confidence" + +# Causality attributes +CAUSE_HINT = "agenttel.cause.hint" +CAUSE_CATEGORY = "agenttel.cause.category" +CAUSE_DEPENDENCY = "agenttel.cause.dependency" +CAUSE_CORRELATED_SPAN_ID = "agenttel.cause.correlated_span_id" +CAUSE_CORRELATED_EVENT_ID = "agenttel.cause.correlated_event_id" +CAUSE_STARTED_AT = "agenttel.cause.started_at" # Decision attributes (operation context an agent needs to act safely) DECISION_RETRYABLE = "agenttel.decision.retryable" +DECISION_RETRY_AFTER_MS = "agenttel.decision.retry_after_ms" DECISION_IDEMPOTENT = "agenttel.decision.idempotent" -DECISION_RUNBOOK_URL = "agenttel.decision.runbook_url" DECISION_FALLBACK_AVAILABLE = "agenttel.decision.fallback_available" DECISION_FALLBACK_DESCRIPTION = "agenttel.decision.fallback_description" +DECISION_RUNBOOK_URL = "agenttel.decision.runbook_url" DECISION_ESCALATION_LEVEL = "agenttel.decision.escalation_level" +DECISION_KNOWN_ISSUE_ID = "agenttel.decision.known_issue_id" DECISION_SAFE_TO_RESTART = "agenttel.decision.safe_to_restart" -# Deprecated aliases (pre-0.4.0 names); values now match attributes.json -OPERATION_RETRYABLE = DECISION_RETRYABLE -OPERATION_IDEMPOTENT = DECISION_IDEMPOTENT -OPERATION_RUNBOOK_URL = DECISION_RUNBOOK_URL -OPERATION_FALLBACK_DESCRIPTION = DECISION_FALLBACK_DESCRIPTION -OPERATION_ESCALATION_LEVEL = DECISION_ESCALATION_LEVEL -OPERATION_SAFE_TO_RESTART = DECISION_SAFE_TO_RESTART -OPERATION_PROFILE = "agenttel.operation.profile" +# Severity attributes +SEVERITY_ANOMALY_SCORE = "agenttel.severity.anomaly_score" +SEVERITY_PATTERN = "agenttel.severity.pattern" +SEVERITY_IMPACT_SCOPE = "agenttel.severity.impact_scope" +SEVERITY_BUSINESS_IMPACT = "agenttel.severity.business_impact" +SEVERITY_USER_FACING = "agenttel.severity.user_facing" -# Baseline attributes -BASELINE_LATENCY_P50_MS = "agenttel.baseline.latency_p50_ms" -BASELINE_LATENCY_P95_MS = "agenttel.baseline.latency_p95_ms" -BASELINE_LATENCY_P99_MS = "agenttel.baseline.latency_p99_ms" -BASELINE_ERROR_RATE = "agenttel.baseline.error_rate" -BASELINE_SOURCE = "agenttel.baseline.source" -BASELINE_CONFIDENCE = "agenttel.baseline.confidence" -BASELINE_SAMPLE_COUNT = "agenttel.baseline.sample_count" +# Deployment attributes +DEPLOYMENT_ID = "agenttel.deployment.id" +DEPLOYMENT_VERSION = "agenttel.deployment.version" +DEPLOYMENT_COMMIT_SHA = "agenttel.deployment.commit_sha" +DEPLOYMENT_PREVIOUS_VERSION = "agenttel.deployment.previous_version" +DEPLOYMENT_STRATEGY = "agenttel.deployment.strategy" +DEPLOYMENT_TIMESTAMP = "agenttel.deployment.timestamp" + +# GenAI extension attributes +GENAI_FRAMEWORK = "agenttel.genai.framework" +GENAI_COST_USD = "agenttel.genai.cost_usd" +GENAI_RAG_SOURCE_COUNT = "agenttel.genai.rag_source_count" +GENAI_RAG_RELEVANCE_SCORE_AVG = "agenttel.genai.rag_relevance_score_avg" +GENAI_GUARDRAIL_TRIGGERED = "agenttel.genai.guardrail_triggered" +GENAI_GUARDRAIL_NAME = "agenttel.genai.guardrail_name" +GENAI_CACHE_HIT = "agenttel.genai.cache_hit" +GENAI_PROMPT_TEMPLATE_ID = "agenttel.genai.prompt_template_id" +GENAI_PROMPT_TEMPLATE_VERSION = "agenttel.genai.prompt_template_version" +GENAI_REASONING_TOKENS = "agenttel.genai.reasoning_tokens" +GENAI_CACHED_READ_TOKENS = "agenttel.genai.cached_read_tokens" +GENAI_CACHED_WRITE_TOKENS = "agenttel.genai.cached_write_tokens" +GENAI_TIME_TO_FIRST_TOKEN_MS = "agenttel.genai.time_to_first_token_ms" # Anomaly attributes ANOMALY_DETECTED = "agenttel.anomaly.detected" -ANOMALY_SCORE = "agenttel.anomaly.score" -ANOMALY_Z_SCORE = "agenttel.anomaly.z_score" ANOMALY_PATTERN = "agenttel.anomaly.pattern" -ANOMALY_BASELINE_MEAN = "agenttel.anomaly.baseline_mean" -ANOMALY_BASELINE_STDDEV = "agenttel.anomaly.baseline_stddev" +ANOMALY_SCORE = "agenttel.anomaly.score" +ANOMALY_LATENCY_Z_SCORE = "agenttel.anomaly.latency_z_score" # SLO attributes SLO_NAME = "agenttel.slo.name" SLO_TARGET = "agenttel.slo.target" -SLO_TYPE = "agenttel.slo.type" SLO_BUDGET_REMAINING = "agenttel.slo.budget_remaining" SLO_BURN_RATE = "agenttel.slo.burn_rate" -SLO_ALERT_LEVEL = "agenttel.slo.alert_level" # Error classification attributes ERROR_CATEGORY = "agenttel.error.category" -ERROR_IS_TRANSIENT = "agenttel.error.is_transient" -ERROR_ROOT_CAUSE = "agenttel.error.root_cause" +ERROR_ROOT_EXCEPTION = "agenttel.error.root_exception" +ERROR_DEPENDENCY = "agenttel.error.dependency" -# Causality attributes -CAUSALITY_CAUSE_CATEGORY = "agenttel.causality.cause_category" -CAUSALITY_BUSINESS_IMPACT = "agenttel.causality.business_impact" -CAUSALITY_IMPACT_SCOPE = "agenttel.causality.impact_scope" -CAUSALITY_DEPENDENCY_CHAIN = "agenttel.causality.dependency_chain" +# Correlation attributes +CORRELATION_LIKELY_CAUSE = "agenttel.correlation.likely_cause" +CORRELATION_CHANGE_ID = "agenttel.correlation.change_id" +CORRELATION_TIME_DELTA_MS = "agenttel.correlation.time_delta_ms" +CORRELATION_CONFIDENCE = "agenttel.correlation.confidence" -# Deployment attributes -DEPLOYMENT_ID = "agenttel.deployment.id" -DEPLOYMENT_VERSION = "agenttel.deployment.version" -DEPLOYMENT_ENVIRONMENT = "agenttel.deployment.environment" -DEPLOYMENT_TIMESTAMP = "agenttel.deployment.timestamp" -DEPLOYMENT_COMMIT_SHA = "agenttel.deployment.commit_sha" +# Agent identity attributes (agents acting on the service, e.g. via MCP) +AGENT_ID = "agenttel.agent.id" +AGENT_ROLE = "agenttel.agent.role" +AGENT_SESSION_ID = "agenttel.agent.session_id" -# GenAI extension attributes -GENAI_FRAMEWORK = "agenttel.genai.framework" -GENAI_COST_USD = "agenttel.genai.cost_usd" -GENAI_CACHE_HIT = "agenttel.genai.cache_hit" -GENAI_RAG_ENABLED = "agenttel.genai.rag_enabled" -GENAI_RAG_SOURCE_COUNT = "agenttel.genai.rag_source_count" -GENAI_GUARDRAIL_TRIGGERED = "agenttel.genai.guardrail_triggered" +# Session attributes +SESSION_ID = "agenttel.session.id" +SESSION_INCIDENT_ID = "agenttel.session.incident_id" + +# Circuit breaker attributes +CIRCUIT_BREAKER_NAME = "agenttel.circuit_breaker.name" +CIRCUIT_BREAKER_PREVIOUS_STATE = "agenttel.circuit_breaker.previous_state" +CIRCUIT_BREAKER_NEW_STATE = "agenttel.circuit_breaker.new_state" +CIRCUIT_BREAKER_FAILURE_COUNT = "agenttel.circuit_breaker.failure_count" +CIRCUIT_BREAKER_DEPENDENCY = "agenttel.circuit_breaker.dependency" + +# Event names +EVENT_DEPLOYMENT_INFO = "agenttel.deployment.info" +EVENT_ANOMALY_DETECTED = "agenttel.anomaly.detected" +EVENT_DEPENDENCY_STATE_CHANGE = "agenttel.dependency.state_change" +EVENT_SLO_BUDGET_ALERT = "agenttel.slo.budget_alert" +EVENT_CIRCUIT_BREAKER_STATE_CHANGE = "agenttel.circuit_breaker.state_change" + +# Deprecated aliases (pre-0.4.0 names); values now match attributes.json +OPERATION_RETRYABLE = DECISION_RETRYABLE +OPERATION_IDEMPOTENT = DECISION_IDEMPOTENT +OPERATION_RUNBOOK_URL = DECISION_RUNBOOK_URL +OPERATION_FALLBACK_DESCRIPTION = DECISION_FALLBACK_DESCRIPTION +OPERATION_ESCALATION_LEVEL = DECISION_ESCALATION_LEVEL +OPERATION_SAFE_TO_RESTART = DECISION_SAFE_TO_RESTART +ANOMALY_Z_SCORE = ANOMALY_LATENCY_Z_SCORE +ERROR_ROOT_CAUSE = ERROR_ROOT_EXCEPTION +CAUSALITY_CAUSE_CATEGORY = CAUSE_CATEGORY +CAUSALITY_BUSINESS_IMPACT = SEVERITY_BUSINESS_IMPACT +CAUSALITY_IMPACT_SCOPE = SEVERITY_IMPACT_SCOPE diff --git a/agenttel-python/src/agenttel/extension_attributes.py b/agenttel-python/src/agenttel/extension_attributes.py new file mode 100644 index 0000000..ce3afbe --- /dev/null +++ b/agenttel-python/src/agenttel/extension_attributes.py @@ -0,0 +1,29 @@ +"""Python-SDK-specific attribute keys that are not in the canonical schema. + +Everything in :mod:`agenttel.attributes`, :mod:`agenttel.agentic_attributes` +and :mod:`agenttel.genai.attributes` matches ``attributes.json`` exactly +(enforced by ``scripts/validate-attributes.sh``). The keys below are still +emitted by the Python SDK but have no canonical equivalent yet; they are +candidates for adding to ``attributes.json`` or for removal. +""" + +# Core +BASELINE_LATENCY_P95_MS = "agenttel.baseline.latency_p95_ms" +OPERATION_PROFILE = "agenttel.operation.profile" + +# GenAI +GENAI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" +GENAI_RETRIEVAL_SOURCE = "gen_ai.retrieval.source" +GENAI_RAG_ENABLED = "agenttel.genai.rag_enabled" + +# Agentic +AGENTIC_STEP_DESCRIPTION = "agenttel.agentic.step.description" +AGENTIC_STEP_TOOL_INPUT = "agenttel.agentic.step.tool_input" +AGENTIC_STEP_TOOL_OUTPUT = "agenttel.agentic.step.tool_output" +AGENTIC_HANDOFF_CONTEXT_KEYS = "agenttel.agentic.handoff.context_keys" +AGENTIC_HUMAN_FEEDBACK = "agenttel.agentic.human.feedback" +AGENTIC_CODE_OUTPUT_SIZE = "agenttel.agentic.code.output_size" +AGENTIC_EVAL_MAX_SCORE = "agenttel.agentic.eval.max_score" +AGENTIC_QUALITY_CONFIDENCE = "agenttel.agentic.quality.confidence" +AGENTIC_GUARDRAIL_INPUT_VIOLATION = "agenttel.agentic.guardrail.input_violation" +AGENTIC_GUARDRAIL_OUTPUT_VIOLATION = "agenttel.agentic.guardrail.output_violation" diff --git a/agenttel-python/src/agenttel/fastapi/decorators.py b/agenttel-python/src/agenttel/fastapi/decorators.py index 2236611..f350826 100644 --- a/agenttel-python/src/agenttel/fastapi/decorators.py +++ b/agenttel-python/src/agenttel/fastapi/decorators.py @@ -8,6 +8,7 @@ from opentelemetry import trace from agenttel import attributes as attr +from agenttel import extension_attributes as ext def agent_operation( @@ -58,7 +59,7 @@ def _set_attributes(span: Any) -> None: if escalation_level: span.set_attribute(attr.DECISION_ESCALATION_LEVEL, escalation_level) if profile: - span.set_attribute(attr.OPERATION_PROFILE, profile) + span.set_attribute(ext.OPERATION_PROFILE, profile) # Set baseline from decorator parameters p50 = _parse_latency(expected_latency_p50) diff --git a/agenttel-python/src/agenttel/genai/attributes.py b/agenttel-python/src/agenttel/genai/attributes.py index f597d18..3fe1bdb 100644 --- a/agenttel-python/src/agenttel/genai/attributes.py +++ b/agenttel-python/src/agenttel/genai/attributes.py @@ -1,4 +1,9 @@ -"""GenAI semantic attributes following OTel GenAI conventions.""" +"""GenAI semantic attributes following OTel GenAI conventions. + +Values match the canonical ``attributes.json`` at the repository root. +SDK-specific keys that are not in the canonical schema live in +:mod:`agenttel.extension_attributes`. +""" # Standard OTel GenAI semantic conventions OPERATION_NAME = "gen_ai.operation.name" @@ -7,9 +12,6 @@ REQUEST_TEMPERATURE = "gen_ai.request.temperature" REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" REQUEST_TOP_P = "gen_ai.request.top_p" -REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences" -REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty" -REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty" RESPONSE_MODEL = "gen_ai.response.model" RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons" @@ -17,27 +19,24 @@ USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" -USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" - -# Extended usage (reasoning, cached) -USAGE_REASONING_TOKENS = "gen_ai.usage.reasoning_tokens" -USAGE_CACHED_TOKENS = "gen_ai.usage.cached_tokens" # AgentTel GenAI extensions FRAMEWORK = "agenttel.genai.framework" COST_USD = "agenttel.genai.cost_usd" CACHE_HIT = "agenttel.genai.cache_hit" -RAG_ENABLED = "agenttel.genai.rag_enabled" RAG_SOURCE_COUNT = "agenttel.genai.rag_source_count" -RAG_RELEVANCE_SCORE = "agenttel.genai.rag_relevance_score" +RAG_RELEVANCE_SCORE_AVG = "agenttel.genai.rag_relevance_score_avg" GUARDRAIL_TRIGGERED = "agenttel.genai.guardrail_triggered" GUARDRAIL_NAME = "agenttel.genai.guardrail_name" -GUARDRAIL_ACTION = "agenttel.genai.guardrail_action" - -# Embedding-specific -EMBEDDING_DIMENSIONS = "gen_ai.embedding.dimensions" -EMBEDDING_VECTOR_COUNT = "gen_ai.embedding.vector_count" +PROMPT_TEMPLATE_ID = "agenttel.genai.prompt_template_id" +PROMPT_TEMPLATE_VERSION = "agenttel.genai.prompt_template_version" +REASONING_TOKENS = "agenttel.genai.reasoning_tokens" +CACHED_READ_TOKENS = "agenttel.genai.cached_read_tokens" +CACHED_WRITE_TOKENS = "agenttel.genai.cached_write_tokens" +TIME_TO_FIRST_TOKEN_MS = "agenttel.genai.time_to_first_token_ms" -# Retrieval-specific -RETRIEVAL_SOURCE = "gen_ai.retrieval.source" -RETRIEVAL_DOCUMENT_COUNT = "gen_ai.retrieval.document_count" +# Deprecated aliases (pre-0.4.0 names); values now match attributes.json +USAGE_REASONING_TOKENS = REASONING_TOKENS +USAGE_CACHED_TOKENS = CACHED_READ_TOKENS +RAG_RELEVANCE_SCORE = RAG_RELEVANCE_SCORE_AVG +RETRIEVAL_DOCUMENT_COUNT = RAG_SOURCE_COUNT diff --git a/agenttel-python/src/agenttel/genai/langchain.py b/agenttel-python/src/agenttel/genai/langchain.py index b65f043..7740671 100644 --- a/agenttel-python/src/agenttel/genai/langchain.py +++ b/agenttel-python/src/agenttel/genai/langchain.py @@ -136,7 +136,6 @@ def on_retriever_start( source=source, rag_enabled=True, ) - span.set_attribute(genai_attr.RAG_ENABLED, True) self._spans[run_id] = span def on_retriever_end( @@ -150,7 +149,6 @@ def on_retriever_end( span = self._spans.pop(run_id, None) if span: span.set_attribute(genai_attr.RAG_SOURCE_COUNT, len(documents)) - span.set_attribute(genai_attr.RETRIEVAL_DOCUMENT_COUNT, len(documents)) span.end() def on_retriever_error( diff --git a/agenttel-python/src/agenttel/genai/span_builder.py b/agenttel-python/src/agenttel/genai/span_builder.py index 942b36f..dae941a 100644 --- a/agenttel-python/src/agenttel/genai/span_builder.py +++ b/agenttel-python/src/agenttel/genai/span_builder.py @@ -7,6 +7,7 @@ from opentelemetry import trace from agenttel.genai import attributes as genai_attr +from agenttel import extension_attributes as ext from agenttel.genai.cost import ModelCostCalculator @@ -76,10 +77,10 @@ def start_retrieval_span( kind=trace.SpanKind.CLIENT, ) span.set_attribute(genai_attr.OPERATION_NAME, "retrieval") - span.set_attribute(genai_attr.RETRIEVAL_SOURCE, source) + span.set_attribute(ext.GENAI_RETRIEVAL_SOURCE, source) if "rag_enabled" in kwargs: - span.set_attribute(genai_attr.RAG_ENABLED, kwargs["rag_enabled"]) + span.set_attribute(ext.GENAI_RAG_ENABLED, kwargs["rag_enabled"]) return span def end_span_with_response( @@ -107,12 +108,12 @@ def end_span_with_response( span.set_attribute(genai_attr.USAGE_INPUT_TOKENS, input_tokens) span.set_attribute(genai_attr.USAGE_OUTPUT_TOKENS, output_tokens) total = input_tokens + output_tokens - span.set_attribute(genai_attr.USAGE_TOTAL_TOKENS, total) + span.set_attribute(ext.GENAI_USAGE_TOTAL_TOKENS, total) if reasoning_tokens > 0: - span.set_attribute(genai_attr.USAGE_REASONING_TOKENS, reasoning_tokens) + span.set_attribute(genai_attr.REASONING_TOKENS, reasoning_tokens) if cached_tokens > 0: - span.set_attribute(genai_attr.USAGE_CACHED_TOKENS, cached_tokens) + span.set_attribute(genai_attr.CACHED_READ_TOKENS, cached_tokens) # Calculate and set cost if model and total > 0: diff --git a/agenttel-python/src/agenttel/processor.py b/agenttel-python/src/agenttel/processor.py index c7f431f..529c79e 100644 --- a/agenttel-python/src/agenttel/processor.py +++ b/agenttel-python/src/agenttel/processor.py @@ -6,6 +6,7 @@ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from agenttel import attributes as attr +from agenttel import extension_attributes as ext from agenttel.anomaly.detector import AnomalyDetector from agenttel.baseline.composite import CompositeBaselineProvider from agenttel.baseline.rolling import RollingBaselineProvider @@ -196,7 +197,7 @@ def _set_baseline_attributes(self, span: Span, baseline: OperationBaseline) -> N span.set_attribute(attr.BASELINE_CONFIDENCE, baseline.confidence.value) span.set_attribute(attr.BASELINE_SAMPLE_COUNT, baseline.sample_count) if baseline.latency_p95_ms > 0: - span.set_attribute(attr.BASELINE_LATENCY_P95_MS, baseline.latency_p95_ms) + span.set_attribute(ext.BASELINE_LATENCY_P95_MS, baseline.latency_p95_ms) def _calculate_latency_ms(self, span: ReadableSpan) -> float: """Calculate span latency in milliseconds.""" diff --git a/agenttel-python/tests/test_agentic.py b/agenttel-python/tests/test_agentic.py index ed44bfc..0d9100d 100644 --- a/agenttel-python/tests/test_agentic.py +++ b/agenttel-python/tests/test_agentic.py @@ -82,3 +82,51 @@ def test_multiple_events(self): assert len(recorder.get_events()) == 3 assert recorder.get_block_count() == 1 + + +class TestAgentTracerEmission: + def test_invocation_emits_canonical_agentic_keys(self, span_exporter): + from agenttel import agentic_attributes as aa + from agenttel.agentic.tracer import AgentTracer + from agenttel.enums import StepType + + tracer = AgentTracer.create().agent_name("responder").framework("custom").build() + with tracer.invoke("diagnose latency") as invocation: + with invocation.step(StepType.THOUGHT, "analyzing"): + pass + with invocation.tool_call("get_health") as tool: + tool.set_result("ok") + invocation.complete(goal_achieved=True) + + spans = {s.name: s for s in span_exporter.get_finished_spans()} + root = spans["agent.invoke responder"].attributes + assert root[aa.AGENTIC_AGENT_NAME] == "responder" + assert root[aa.AGENTIC_AGENT_FRAMEWORK] == "custom" + assert root[aa.AGENTIC_INVOCATION_GOAL] == "diagnose latency" + assert root[aa.AGENTIC_INVOCATION_STATUS] == "completed" + assert root[aa.AGENTIC_INVOCATION_STEPS] == 2 + assert root[aa.AGENTIC_QUALITY_GOAL_ACHIEVED] is True + + tool_attrs = spans["agent.tool_call get_health"].attributes + assert tool_attrs[aa.AGENTIC_STEP_TOOL_NAME] == "get_health" + assert tool_attrs[aa.AGENTIC_STEP_TOOL_STATUS] == "success" + + for span in spans.values(): + for key in span.attributes: + assert key.startswith("agenttel.agentic."), key + + def test_fail_marks_invocation_failed(self, span_exporter): + from opentelemetry.trace import StatusCode + + from agenttel import agentic_attributes as aa + from agenttel.agentic.tracer import AgentTracer + + tracer = AgentTracer.create().agent_name("responder").build() + with tracer.invoke("goal") as invocation: + invocation.fail(error_source="tool", error_message="timeout") + + (span,) = span_exporter.get_finished_spans() + assert span.attributes[aa.AGENTIC_INVOCATION_STATUS] == "failed" + assert span.attributes[aa.AGENTIC_ERROR_SOURCE] == "tool" + assert span.status.status_code == StatusCode.ERROR + assert span.status.description == "timeout" diff --git a/agenttel-python/tests/test_attributes.py b/agenttel-python/tests/test_attributes.py index 3578972..0292497 100644 --- a/agenttel-python/tests/test_attributes.py +++ b/agenttel-python/tests/test_attributes.py @@ -10,10 +10,9 @@ def test_topology_attributes_have_correct_prefix(self): assert attr.TOPOLOGY_TIER == "agenttel.topology.tier" assert attr.TOPOLOGY_DOMAIN == "agenttel.topology.domain" - def test_dependency_attributes(self): - assert attr.DEPENDENCY_NAME == "agenttel.dependency.name" - assert attr.DEPENDENCY_TYPE == "agenttel.dependency.type" - assert attr.DEPENDENCY_CRITICALITY == "agenttel.dependency.criticality" + def test_topology_relationship_attributes(self): + assert attr.TOPOLOGY_DEPENDENCIES == "agenttel.topology.dependencies" + assert attr.TOPOLOGY_CONSUMERS == "agenttel.topology.consumers" def test_baseline_attributes(self): assert attr.BASELINE_LATENCY_P50_MS == "agenttel.baseline.latency_p50_ms" @@ -23,7 +22,8 @@ def test_baseline_attributes(self): def test_anomaly_attributes(self): assert attr.ANOMALY_DETECTED == "agenttel.anomaly.detected" assert attr.ANOMALY_SCORE == "agenttel.anomaly.score" - assert attr.ANOMALY_Z_SCORE == "agenttel.anomaly.z_score" + assert attr.ANOMALY_LATENCY_Z_SCORE == "agenttel.anomaly.latency_z_score" + assert attr.ANOMALY_Z_SCORE == attr.ANOMALY_LATENCY_Z_SCORE # deprecated alias def test_slo_attributes(self): assert attr.SLO_NAME == "agenttel.slo.name" @@ -43,18 +43,29 @@ def test_all_attributes_have_agenttel_prefix(self): class TestAgenticAttributes: def test_agent_identity_attributes(self): - assert aa.AGENT_NAME == "agenttel.agent.name" - assert aa.AGENT_TYPE == "agenttel.agent.type" - assert aa.AGENT_FRAMEWORK == "agenttel.agent.framework" + assert aa.AGENTIC_AGENT_NAME == "agenttel.agentic.agent.name" + assert aa.AGENTIC_AGENT_TYPE == "agenttel.agentic.agent.type" + assert aa.AGENTIC_AGENT_FRAMEWORK == "agenttel.agentic.agent.framework" def test_invocation_attributes(self): - assert aa.INVOCATION_ID == "agenttel.invocation.id" - assert aa.INVOCATION_GOAL == "agenttel.invocation.goal" - assert aa.INVOCATION_STATUS == "agenttel.invocation.status" + assert aa.AGENTIC_INVOCATION_ID == "agenttel.agentic.invocation.id" + assert aa.AGENTIC_INVOCATION_GOAL == "agenttel.agentic.invocation.goal" + assert aa.AGENTIC_INVOCATION_STATUS == "agenttel.agentic.invocation.status" def test_cost_attributes(self): - assert aa.COST_TOTAL_USD == "agenttel.cost.total_usd" - assert aa.COST_INPUT_TOKENS == "agenttel.cost.input_tokens" + assert aa.AGENTIC_COST_TOTAL_USD == "agenttel.agentic.cost.total_usd" + assert aa.AGENTIC_COST_INPUT_TOKENS == "agenttel.agentic.cost.input_tokens" + + def test_deprecated_aliases_point_at_canonical_keys(self): + assert aa.AGENT_NAME == aa.AGENTIC_AGENT_NAME + assert aa.STEP_TOOL_NAME == aa.AGENTIC_STEP_TOOL_NAME + assert aa.ORCHESTRATION_STAGE_COUNT == aa.AGENTIC_ORCHESTRATION_TOTAL_STAGES + + def test_all_agentic_attributes_use_agentic_namespace(self): + for name in dir(aa): + if name.isupper() and not name.startswith("_"): + value = getattr(aa, name) + assert value.startswith("agenttel.agentic."), f"{name}={value}" def test_all_agentic_attributes_have_prefix(self): for name in dir(aa):