diff --git a/pyproject.toml b/pyproject.toml index 8efdb73..1a03cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,8 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ + "opentelemetry-exporter-otlp-proto-http>=1.38.0,<2", + "opentelemetry-sdk>=1.38.0,<2", "pyright>=1.1.410", "ruff>=0.15.16", ] @@ -30,6 +32,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.28.1,<1", + "opentelemetry-api>=1.38.0,<2", "pydantic>=2.13.4,<3", "python-dotenv>=1.2.2,<2", ] @@ -41,6 +44,12 @@ keywords = [ Homepage = "https://github.com/sourcegraph/src-py-lib" Issues = "https://github.com/sourcegraph/src-py-lib/issues" +[project.optional-dependencies] +otel = [ + "opentelemetry-exporter-otlp-proto-http>=1.38.0,<2", + "opentelemetry-sdk>=1.38.0,<2", +] + [tool.hatch.build.targets.wheel] packages = ["src/src_py_lib"] diff --git a/src/src_py_lib/__init__.py b/src/src_py_lib/__init__.py index deaa203..be840d9 100644 --- a/src/src_py_lib/__init__.py +++ b/src/src_py_lib/__init__.py @@ -73,29 +73,32 @@ from src_py_lib.utils.logging import ( LoggingConfig, LoggingSettings, - TraceContext, configure_logging, critical, - current_trace_context, debug, error, - event, info, - log, log_context, + log_event, logging_context, logging_settings_from_config, - new_trace_context, resolve_log_level_name, - sampled_traceparent, + span, stage, startup_event, submit_with_log_context, - trace_context, - trace_context_from_traceparent, - traceparent_header, warning, ) +from src_py_lib.utils.telemetry import ( + OpenTelemetryConfig, + OpenTelemetryRuntime, + OpenTelemetrySettings, + OpenTelemetrySetupError, + configure_open_telemetry, + current_traceparent_header, + open_telemetry_settings_from_config, + traceparent_fields, +) from src_py_lib.utils.tsv import write_tsv @@ -105,15 +108,33 @@ def logging( command: str | None = None, git_cwd: Path | str | None = None, logging_config: LoggingSettings | None = None, + open_telemetry: OpenTelemetrySettings | None = None, run_fields: Mapping[str, Any] | None = None, run_summary: Callable[[], Mapping[str, Any]] | None = None, ) -> AbstractContextManager[Path | None]: """Configure standard CLI logging and emit startup metadata.""" + resolved_logging_config = logging_config + if open_telemetry is not None: + resolved_logging_config = logging_config or logging_settings_from_config(config) + resolved_logging_config = LoggingSettings( + logger_name=resolved_logging_config.logger_name, + terminal_level=resolved_logging_config.terminal_level, + log_file_level=resolved_logging_config.log_file_level, + log_file=resolved_logging_config.log_file, + logs_dir=resolved_logging_config.logs_dir, + run=resolved_logging_config.run, + retain_log_files=resolved_logging_config.retain_log_files, + suppress_http_dependency_logs=resolved_logging_config.suppress_http_dependency_logs, + resource_sample_interval_seconds=( + resolved_logging_config.resource_sample_interval_seconds + ), + open_telemetry=open_telemetry, + ) return logging_context( command or _script_name(), config, git_cwd=git_cwd, - logging_config=logging_config, + logging_config=resolved_logging_config, run_fields=run_fields, run_summary=run_summary, ) @@ -139,6 +160,10 @@ def _script_name() -> str: "LinearClientConfig", "LoggingConfig", "LoggingSettings", + "OpenTelemetryConfig", + "OpenTelemetryRuntime", + "OpenTelemetrySettings", + "OpenTelemetrySetupError", "PullRequest", "SlackClient", "SlackClientConfig", @@ -149,15 +174,15 @@ def _script_name() -> str: "SourcegraphJaegerTraceError", "SourcegraphJaegerTraceSummary", "SourcegraphTrace", - "TraceContext", "aliased_batched_query", "config_field", "config_field_names", "config_help_formatter", "config_snapshot", + "configure_open_telemetry", "configure_logging", "critical", - "current_trace_context", + "current_traceparent_header", "debug", "decode_external_service_id", "decode_repository_id", @@ -165,7 +190,7 @@ def _script_name() -> str: "encode_repository_id", "encode_sourcegraph_node_id", "error", - "event", + "span", "gh_cli_token", "gcloud_adc_access_token", "info", @@ -182,25 +207,22 @@ def _script_name() -> str: "logging", "logging_context", "logging_settings_from_config", - "log", + "log_event", "log_context", - "new_trace_context", "normalize_sourcegraph_endpoint", + "open_telemetry_settings_from_config", "parse_args", "pr_ref_from_url", "quota_project_from_adc", "resolve_log_level_name", "save_json_cache", - "sampled_traceparent", "slack_client_from_config", "sourcegraph_client_from_config", "stage", "startup_event", "stream_connection_nodes", "submit_with_log_context", - "trace_context", - "trace_context_from_traceparent", - "traceparent_header", + "traceparent_fields", "warning", "write_tsv", ] diff --git a/src/src_py_lib/clients/graphql.py b/src/src_py_lib/clients/graphql.py index 2478bb6..b8f56a6 100644 --- a/src/src_py_lib/clients/graphql.py +++ b/src/src_py_lib/clients/graphql.py @@ -11,7 +11,7 @@ from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse, log_safe_url from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list, json_str -from src_py_lib.utils.logging import event +from src_py_lib.utils.logging import span _OPERATION_NAME_RE = re.compile(r"\b(?:query|mutation|subscription)\s+(\w+)") HeaderProvider = Mapping[str, str] | Callable[[], Mapping[str, str]] @@ -241,7 +241,7 @@ def _execute_once( after_variable: str = "after", ) -> JSONDict: body = {"query": query, "variables": variables or {}} - with event( + with span( "graphql_query", level="debug", graphql_client=self.label, diff --git a/src/src_py_lib/clients/sourcegraph.py b/src/src_py_lib/clients/sourcegraph.py index 79a77fb..85b73cd 100644 --- a/src/src_py_lib/clients/sourcegraph.py +++ b/src/src_py_lib/clients/sourcegraph.py @@ -10,19 +10,18 @@ from collections.abc import Iterable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field -from typing import Final, cast +from typing import Final from urllib.parse import urlsplit from src_py_lib.clients.graphql import GraphQLClient, stream_connection_nodes from src_py_lib.utils.config import Config, config_field from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list -from src_py_lib.utils.logging import ( - current_trace_context, - new_trace_context, - submit_with_log_context, - trace_context_from_traceparent, - traceparent_header, +from src_py_lib.utils.logging import submit_with_log_context +from src_py_lib.utils.telemetry import ( + current_traceparent_header, + set_current_span_attributes, + traceparent_fields, ) SOURCEGRAPH_EXTERNAL_SERVICE_NODE_TYPE: Final[str] = "ExternalService" @@ -187,16 +186,16 @@ class SourcegraphClient: Plain HTTP endpoints are rejected unless `allow_insecure_http=True` is set for local development. - Set `trace=True` to ask Sourcegraph to retain traces for each GraphQL - request. Traced requests are available through `drain_traces()` and can be - fetched from the instance's Jaeger/debug endpoint with + Set `fetch_sg_traces=True` to ask Sourcegraph to retain traces for each + GraphQL request. Traced requests are available through `drain_traces()` and + can be fetched from the instance's Jaeger/debug endpoint with `stream_jaeger_trace_summaries()`. """ endpoint: str token: str http: HTTPClient = field(default_factory=HTTPClient) - trace: bool = False + fetch_sg_traces: bool = False allow_insecure_http: bool = False _traces: queue.Queue[SourcegraphTrace] = field( default_factory=lambda: queue.Queue[SourcegraphTrace](), init=False, repr=False @@ -355,7 +354,7 @@ def _client(self) -> GraphQLClient: headers=self._graphql_headers, label="Sourcegraph", http=self.http, - response_hook=self._record_trace_response if self.trace else None, + response_hook=self._record_trace_response if self.fetch_sg_traces else None, ) def _authorization_headers(self) -> dict[str, str]: @@ -363,11 +362,11 @@ def _authorization_headers(self) -> dict[str, str]: def _graphql_headers(self) -> dict[str, str]: headers = self._authorization_headers() - if self.trace: + if self.fetch_sg_traces: headers[REQUEST_TRACE_HEADER] = "true" - headers[TRACEPARENT_HEADER] = traceparent_header( - current_trace_context() or new_trace_context() - ) + traceparent = current_traceparent_header() + if traceparent is not None: + headers[TRACEPARENT_HEADER] = traceparent return headers def _record_trace_response( @@ -375,6 +374,13 @@ def _record_trace_response( ) -> None: trace = sourcegraph_trace_from_headers(response.headers, request_headers) if trace is not None: + set_current_span_attributes( + { + "sourcegraph.trace_id": trace.trace_id, + "sourcegraph.trace_url": trace.trace_url, + "sourcegraph.span_id": trace.span_id, + } + ) self._traces.put(trace) @@ -382,22 +388,17 @@ def sourcegraph_client_from_config( config: SourcegraphClientConfig, *, http: HTTPClient | None = None, - trace: bool = False, + fetch_sg_traces: bool = False, ) -> SourcegraphClient: """Return a Sourcegraph API client from shared Sourcegraph Config fields.""" return SourcegraphClient( endpoint=config.src_endpoint, token=config.src_access_token, http=http or HTTPClient(), - trace=trace, + fetch_sg_traces=fetch_sg_traces, ) -def sampled_traceparent() -> str: - """Compatibility wrapper for sampled W3C traceparent generation.""" - return traceparent_header(sampled=True) - - def sourcegraph_trace_from_headers( response_headers: Mapping[str, str], request_headers: Mapping[str, str] ) -> SourcegraphTrace | None: @@ -407,13 +408,13 @@ def sourcegraph_trace_from_headers( return None span_id = header_value(response_headers, TRACE_SPAN_RESPONSE_HEADER) trace_url = header_value(response_headers, TRACE_URL_RESPONSE_HEADER) - parent = trace_context_from_traceparent(header_value(request_headers, TRACEPARENT_HEADER)) + parent = traceparent_fields(header_value(request_headers, TRACEPARENT_HEADER)) return SourcegraphTrace( trace_id=trace_id.lower(), span_id=span_id.lower() if span_id and is_hex_identifier(span_id, 16) else span_id, trace_url=trace_url, - parent_trace_id=parent.trace_id if parent is not None else None, - parent_span_id=parent.span_id if parent is not None else None, + parent_trace_id=parent.get("trace_id"), + parent_span_id=parent.get("span_id"), ) @@ -472,7 +473,7 @@ def summarize_jaeger_trace( } ) - hot_operations = [ + hot_operations: list[JSONDict] = [ { "operation": operation, "count": len(durations), @@ -481,12 +482,12 @@ def summarize_jaeger_trace( } for operation, durations in durations_by_operation.items() ] - hot_operations.sort(key=lambda operation: float(operation["sum_ms"]), reverse=True) + hot_operations.sort(key=jaeger_summary_operation_sum_ms, reverse=True) return SourcegraphJaegerTraceSummary( trace=trace_metadata, jaeger_found=True, span_count=len(spans), - hot_operations=tuple(cast(JSONDict, operation) for operation in hot_operations[:10]), + hot_operations=tuple(hot_operations[:10]), graphql_operations=tuple( {"operation": operation, "count": count} for operation, count in graphql_operations.most_common(10) @@ -495,6 +496,11 @@ def summarize_jaeger_trace( ) +def jaeger_summary_operation_sum_ms(operation: JSONDict) -> float: + """Return the total duration for sorting compact Jaeger operation summaries.""" + return float_value(operation.get("sum_ms")) + + def jaeger_span_tags(span: JSONDict) -> dict[str, object]: """Return Jaeger span tags keyed by tag name.""" tags: dict[str, object] = {} diff --git a/src/src_py_lib/utils/http.py b/src/src_py_lib/utils/http.py index bf0505f..8e50a55 100644 --- a/src/src_py_lib/utils/http.py +++ b/src/src_py_lib/utils/http.py @@ -14,7 +14,13 @@ import httpx from src_py_lib.utils.json_types import JSONDict, json_dict -from src_py_lib.utils.logging import event, record_http_attempt, record_http_retry +from src_py_lib.utils.logging import record_http_attempt, record_http_retry, span +from src_py_lib.utils.telemetry import ( + inject_current_trace_context, + mark_current_span_error, + record_http_client_metrics, + record_http_client_retry, +) DEFAULT_TIMEOUT_SECONDS: Final[float] = 30.0 DEFAULT_MAX_CONNECTIONS: Final[int] = 20 @@ -158,8 +164,9 @@ def response( body = json.dumps(json_body).encode("utf-8") request_headers.setdefault("Content-Type", "application/json") for attempt in range(1, self.max_attempts + 1): + attempt_started = time.perf_counter() try: - with event( + with span( "http_request", level="debug", method=method, @@ -168,6 +175,7 @@ def response( request_headers=_headers_for_log(request_headers), request_bytes=len(body or b""), ) as fields: + inject_current_trace_context(request_headers) response = self._client.request( method, request_url, @@ -182,12 +190,22 @@ def response( http_version = _response_http_version(response) if http_version is not None: fields["http_version"] = http_version + duration_seconds = time.perf_counter() - attempt_started record_http_attempt( request_bytes=len(body or b""), response_bytes=len(payload), status_code=response.status_code, ) + record_http_client_metrics( + method=method, + url=request_url, + duration_seconds=duration_seconds, + request_bytes=len(body or b""), + response_bytes=len(payload), + status_code=response.status_code, + ) if response.status_code >= 400: + mark_current_span_error(f"HTTP {response.status_code}") body_text = _body_preview(payload) if not self._should_retry(response.status_code, attempt): raise HTTPClientError( @@ -198,6 +216,11 @@ def response( headers=dict(response.headers), ) record_http_retry() + record_http_client_retry( + method=method, + url=request_url, + status_code=response.status_code, + ) self._sleep_before_retry(attempt, response.headers.get("Retry-After")) else: return HTTPResponse( @@ -210,7 +233,15 @@ def response( except HTTPClientError: raise except httpx.TransportError as exception: + duration_seconds = time.perf_counter() - attempt_started record_http_attempt(request_bytes=len(body or b""), transport_error=True) + record_http_client_metrics( + method=method, + url=request_url, + duration_seconds=duration_seconds, + request_bytes=len(body or b""), + transport_error=True, + ) if not self._should_retry(None, attempt): failure = ( "timed out" if isinstance(exception, httpx.TimeoutException) else "failed" @@ -220,6 +251,7 @@ def response( f"{_exception_message(exception)}" ) from exception record_http_retry() + record_http_client_retry(method=method, url=request_url) self._sleep_before_retry(attempt, None) raise AssertionError("HTTP retry loop exited without returning or raising") diff --git a/src/src_py_lib/utils/logging.py b/src/src_py_lib/utils/logging.py index d748b64..453b520 100644 --- a/src/src_py_lib/utils/logging.py +++ b/src/src_py_lib/utils/logging.py @@ -2,7 +2,7 @@ Use `configure_logging()` once near process startup. Other modules should use `logging.getLogger(__name__)` for human-readable operator messages and -`event()` / `log()` for structured JSONL events. +`span()` / `log_event()` for structured JSONL events. """ from __future__ import annotations @@ -30,6 +30,7 @@ from pydantic import model_validator +from src_py_lib.utils import telemetry from src_py_lib.utils.config import Config, config_field, config_snapshot RUN: Final[str] = secrets.token_hex(4) @@ -40,8 +41,6 @@ SRC_LOG_VERBOSE: Final[str] = "SRC_LOG_VERBOSE" SRC_LOG_QUIET: Final[str] = "SRC_LOG_QUIET" SRC_LOG_SILENT: Final[str] = "SRC_LOG_SILENT" -TRACE_ID_BYTES: Final[int] = 16 -SPAN_ID_BYTES: Final[int] = 8 MEBIBYTE: Final[int] = 1024 * 1024 REDACTED_LOG_VALUE: Final[str] = "[redacted]" SECRET_FIELD_FRAGMENTS: Final[tuple[str, ...]] = ( @@ -74,6 +73,9 @@ _HTTPX_REQUEST_PREFIX: Final[str] = "HTTP Request: " _HTTP_DEPENDENCY_LOGGER_PREFIXES: Final[tuple[str, ...]] = ("httpx", "httpcore") _CONTEXT: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar("src_py_lib_log_context") +_PARENT_SPAN_CONTEXT: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "src_py_lib_parent_span_id", default=None +) @dataclass(frozen=True) @@ -89,6 +91,7 @@ class LoggingSettings: retain_log_files: int = DEFAULT_RETAIN_FILES suppress_http_dependency_logs: bool = True resource_sample_interval_seconds: float | None = None + open_telemetry: telemetry.OpenTelemetrySettings | None = None class LoggingConfig(Config): @@ -178,6 +181,7 @@ def logging_settings_from_config( retain_log_files: int = DEFAULT_RETAIN_FILES, suppress_http_dependency_logs: bool = True, resource_sample_interval_seconds: float | None = None, + open_telemetry: telemetry.OpenTelemetrySettings | None = None, ) -> LoggingSettings: """Return `LoggingSettings` using common CLI log-level alias.""" explicit_level = resolve_log_level_name(config) @@ -191,55 +195,10 @@ def logging_settings_from_config( retain_log_files=retain_log_files, suppress_http_dependency_logs=suppress_http_dependency_logs, resource_sample_interval_seconds=resource_sample_interval_seconds, + open_telemetry=open_telemetry, ) -@dataclass(frozen=True) -class TraceContext: - """W3C-compatible trace/span identifiers for logs and outbound requests.""" - - trace_id: str - span_id: str - parent_span_id: str | None = None - - def __post_init__(self) -> None: - if not _is_hex_identifier(self.trace_id, TRACE_ID_BYTES * 2): - raise ValueError("trace_id must be a non-zero 32-character hex string") - if not _is_hex_identifier(self.span_id, SPAN_ID_BYTES * 2): - raise ValueError("span_id must be a non-zero 16-character hex string") - if self.parent_span_id is not None and not _is_hex_identifier( - self.parent_span_id, SPAN_ID_BYTES * 2 - ): - raise ValueError("parent_span_id must be a non-zero 16-character hex string") - - @property - def trace(self) -> str: - """Return the log-field trace identifier.""" - return self.trace_id - - @property - def span(self) -> str: - """Return the log-field span identifier.""" - return self.span_id - - @property - def parent_span(self) -> str | None: - """Return the log-field parent span identifier.""" - return self.parent_span_id - - def child(self) -> TraceContext: - """Return a child span in the same trace.""" - return new_trace_context(self) - - def traceparent(self, *, sampled: bool = True) -> str: - """Return this context as a W3C traceparent header value.""" - return traceparent_header(self, sampled=sampled) - - -_SPAN_CONTEXT: contextvars.ContextVar[TraceContext | None] = contextvars.ContextVar( - "src_py_lib_span_context", default=None -) - _HTTP_METRICS_LOCK: Final[threading.Lock] = threading.Lock() _HTTP_METRICS: dict[str, int] = { "http_request_attempt_count": 0, @@ -291,7 +250,7 @@ def start(self) -> None: def emit_sample(self) -> None: """Emit one DEBUG `resource_sample` event.""" - log("debug", "resource_sample", **self._sample_fields()) + log_event("debug", "resource_sample", **self._sample_fields()) def stop_and_summary(self) -> dict[str, Any]: """Stop periodic sampling and return run-end resource fields.""" @@ -527,50 +486,62 @@ def logging_context( resolved_logging_config = logging_config or LoggingSettings( log_file_level=_src_log_level_from_config(config) ) + open_telemetry_runtime = telemetry.configure_open_telemetry( + resolved_logging_config.open_telemetry or telemetry.OpenTelemetrySettings() + ) log_file = configure_logging(resolved_logging_config) sampler = _resource_sampler(resolved_logging_config) started = time.perf_counter() error: BaseException | None = None - with log_context(command=name): - if sampler is not None: - sampler.start() - start_fields = {"phase": "start", **dict(run_fields or {})} - info("run", logger_name=resolved_logging_config.logger_name, **start_fields) - try: - startup_event( - command=name, - config=config, - log_file=log_file, - git_cwd=_git_cwd_path(git_cwd), - logger_name=resolved_logging_config.logger_name, - ) - yield log_file - except BaseException as exception: - error = exception - raise - finally: - error_type = _run_error_type(error) - summary: dict[str, Any] = {} + try: + with ( + log_context(command=name), + telemetry.open_telemetry_span(name, {"command": name, **dict(run_fields or {})}), + ): if sampler is not None: - summary.update(sampler.stop_and_summary()) - summary.update(observability_summary()) - summary["exit_code"] = _run_exit_code(error) - if run_summary is not None: - summary.update(dict(run_summary())) - end_fields = { - "phase": "end", - "duration_ms": round((time.perf_counter() - started) * 1000.0), - "status": "error" if error_type else "ok", - "error_type": error_type, - **dict(run_fields or {}), - **summary, - } - log( - "error" if error_type else "info", - "run", - logger_name=resolved_logging_config.logger_name, - **end_fields, - ) + sampler.start() + start_fields = {"phase": "start", **dict(run_fields or {})} + info("run", logger_name=resolved_logging_config.logger_name, **start_fields) + try: + startup_event( + command=name, + config=config, + log_file=log_file, + git_cwd=_git_cwd_path(git_cwd), + logger_name=resolved_logging_config.logger_name, + ) + yield log_file + except BaseException as exception: + error = exception + raise + finally: + error_type = _run_error_type(error) + summary: dict[str, Any] = {} + if sampler is not None: + summary.update(sampler.stop_and_summary()) + summary.update(observability_summary()) + summary["exit_code"] = _run_exit_code(error) + if run_summary is not None: + summary.update(dict(run_summary())) + end_fields = { + "phase": "end", + "duration_ms": round((time.perf_counter() - started) * 1000.0), + "status": "error" if error_type else "ok", + "error_type": error_type, + **dict(run_fields or {}), + **summary, + } + telemetry.set_current_span_attributes(end_fields) + if error_type: + telemetry.mark_current_span_error(error_type) + log_event( + "error" if error_type else "info", + "run", + logger_name=resolved_logging_config.logger_name, + **end_fields, + ) + finally: + open_telemetry_runtime.force_flush() def default_log_file(logs_dir: Path = DEFAULT_LOGS_DIR, *, run: str = RUN) -> Path: @@ -580,12 +551,13 @@ def default_log_file(logs_dir: Path = DEFAULT_LOGS_DIR, *, run: str = RUN) -> Pa return logs_dir / f"{timestamp}-{run}.json" -def log(level: str, key: str, *, logger_name: str = "", **fields: Any) -> None: +def log_event(level: str, key: str, *, logger_name: str = "", **fields: Any) -> None: """Log one structured event through the configured logger.""" numeric_level = _log_level(level) logger = logging.getLogger(logger_name) if not logger.isEnabledFor(numeric_level): return + telemetry.add_span_event(key, {"level": logging.getLevelName(numeric_level), **fields}) logger.log( numeric_level, "event=%s", @@ -599,32 +571,32 @@ def log(level: str, key: str, *, logger_name: str = "", **fields: Any) -> None: def debug(key: str, *, logger_name: str = "", **fields: Any) -> None: """Log a DEBUG structured event.""" - log("debug", key, logger_name=logger_name, **fields) + log_event("debug", key, logger_name=logger_name, **fields) def info(key: str, *, logger_name: str = "", **fields: Any) -> None: """Log an INFO structured event.""" - log("info", key, logger_name=logger_name, **fields) + log_event("info", key, logger_name=logger_name, **fields) def warning(key: str, *, logger_name: str = "", **fields: Any) -> None: """Log a WARNING structured event.""" - log("warning", key, logger_name=logger_name, **fields) + log_event("warning", key, logger_name=logger_name, **fields) def error(key: str, *, logger_name: str = "", **fields: Any) -> None: """Log an ERROR structured event.""" - log("error", key, logger_name=logger_name, **fields) + log_event("error", key, logger_name=logger_name, **fields) def critical(key: str, *, logger_name: str = "", **fields: Any) -> None: """Log a CRITICAL structured event.""" - log("critical", key, logger_name=logger_name, **fields) + log_event("critical", key, logger_name=logger_name, **fields) @contextlib.contextmanager def log_context(**fields: Any) -> Generator[None]: - """Add inherited structured fields for nested `log()` calls.""" + """Add inherited structured fields for nested `log_event()` calls.""" reset_token = _CONTEXT.set({**_CONTEXT.get({}), **fields}) try: yield @@ -639,70 +611,8 @@ def stage(name: str, **fields: Any) -> Generator[None]: yield -def current_trace_context() -> TraceContext | None: - """Return the current logging trace/span context, if one is active.""" - return _SPAN_CONTEXT.get() - - -def new_trace_context(parent: TraceContext | None = None) -> TraceContext: - """Return a root or child trace/span context. - - When `parent` is omitted, the current context is used as the parent when - available. Otherwise a new root trace is created. - """ - resolved_parent = parent if parent is not None else current_trace_context() - if resolved_parent is None: - return TraceContext( - trace_id=_nonzero_hex(TRACE_ID_BYTES), - span_id=_nonzero_hex(SPAN_ID_BYTES), - ) - return TraceContext( - trace_id=resolved_parent.trace_id, - span_id=_nonzero_hex(SPAN_ID_BYTES), - parent_span_id=resolved_parent.span_id, - ) - - @contextlib.contextmanager -def trace_context(context: TraceContext | None = None) -> Generator[TraceContext]: - """Set a trace/span context for nested logs and outbound requests.""" - resolved_context = context or new_trace_context() - reset_token = _SPAN_CONTEXT.set(resolved_context) - try: - yield resolved_context - finally: - _SPAN_CONTEXT.reset(reset_token) - - -def traceparent_header(context: TraceContext | None = None, *, sampled: bool = True) -> str: - """Return a W3C traceparent header for `context` or the current context.""" - resolved_context = context or current_trace_context() or new_trace_context() - flags = "01" if sampled else "00" - return f"00-{resolved_context.trace_id}-{resolved_context.span_id}-{flags}" - - -def sampled_traceparent(context: TraceContext | None = None) -> str: - """Return a sampled W3C traceparent header value.""" - return traceparent_header(context, sampled=True) - - -def trace_context_from_traceparent(value: str | None) -> TraceContext | None: - """Return trace/span identifiers parsed from a W3C traceparent header.""" - if value is None: - return None - parts = value.split("-") - if len(parts) != 4 or parts[0] != "00": - return None - trace_id = parts[1].lower() - span_id = parts[2].lower() - try: - return TraceContext(trace_id=trace_id, span_id=span_id) - except ValueError: - return None - - -@contextlib.contextmanager -def event( +def span( key: str, *, level: str = "info", @@ -711,40 +621,43 @@ def event( logger_name: str = "", **fields: Any, ) -> Generator[dict[str, Any]]: - """Emit start/end structured events around a block of work.""" - span = new_trace_context() - reset_token = _SPAN_CONTEXT.set(span) + """Open an observed span and emit start/end structured log events.""" + parent_span_id = telemetry.current_span_id() + parent_reset_token = _PARENT_SPAN_CONTEXT.set(parent_span_id) try: - log(start_level or level, key, logger_name=logger_name, phase="start", **fields) - started = time.perf_counter() - extra: dict[str, Any] = {} - error: BaseException | None = None - try: - yield extra - except BaseException as exception: - error = exception - raise - finally: - end_fields = { - **fields, - **extra, - "phase": "end", - "duration_ms": round((time.perf_counter() - started) * 1000.0), - } - if error: - end_fields["status"] = "error" - end_fields["error_type"] = type(error).__name__ - elif not omit_success_status: - end_fields["status"] = "ok" - end_fields["error_type"] = None - log( - "error" if error else level, - key, - logger_name=logger_name, - **end_fields, - ) + with telemetry.open_telemetry_span(key, fields): + log_event(start_level or level, key, logger_name=logger_name, phase="start", **fields) + started = time.perf_counter() + extra: dict[str, Any] = {} + error: BaseException | None = None + try: + yield extra + except BaseException as exception: + error = exception + raise + finally: + end_fields = { + **fields, + **extra, + "phase": "end", + "duration_ms": round((time.perf_counter() - started) * 1000.0), + } + if error: + end_fields["status"] = "error" + end_fields["error_type"] = type(error).__name__ + telemetry.mark_current_span_error(type(error).__name__) + elif not omit_success_status: + end_fields["status"] = "ok" + end_fields["error_type"] = None + telemetry.set_current_span_attributes(end_fields) + log_event( + "error" if error else level, + key, + logger_name=logger_name, + **end_fields, + ) finally: - _SPAN_CONTEXT.reset(reset_token) + _PARENT_SPAN_CONTEXT.reset(parent_reset_token) def submit_with_log_context( @@ -788,15 +701,10 @@ def sanitized_config_snapshot(config: object) -> dict[str, Any]: def _current_log_fields(protected: Mapping[str, Any] | None = None) -> dict[str, Any]: protected_keys = set(protected or {}) fields = {key: value for key, value in _CONTEXT.get({}).items() if key not in protected_keys} - span = _SPAN_CONTEXT.get() - if span is None: - return fields - if "parent_span" not in protected_keys and span.parent_span is not None: - fields["parent_span"] = span.parent_span - if "span" not in protected_keys: - fields["span"] = span.span - if "trace" not in protected_keys: - fields["trace"] = span.trace + trace_fields = telemetry.current_trace_fields(_PARENT_SPAN_CONTEXT.get()) + for key, value in trace_fields.items(): + if key not in protected_keys: + fields[key] = value return fields @@ -962,22 +870,6 @@ def _decode_http_bytes(value: object) -> str | None: return None -def _nonzero_hex(byte_count: int) -> str: - while True: - value = secrets.token_hex(byte_count) - if any(character != "0" for character in value): - return value - - -def _is_hex_identifier(value: str, length: int) -> bool: - lowered = value.lower() - return ( - len(lowered) == length - and any(character != "0" for character in lowered) - and all(character in "0123456789abcdef" for character in lowered) - ) - - def _is_sensitive_log_field(name: str) -> bool: lowered = name.lower() return any(fragment in lowered for fragment in SECRET_FIELD_FRAGMENTS) diff --git a/src/src_py_lib/utils/telemetry.py b/src/src_py_lib/utils/telemetry.py new file mode 100644 index 0000000..40f2092 --- /dev/null +++ b/src/src_py_lib/utils/telemetry.py @@ -0,0 +1,494 @@ +"""OpenTelemetry bootstrap and small instrumentation helpers.""" + +from __future__ import annotations + +import importlib +import json +import os +import urllib.parse +from collections.abc import Mapping, MutableMapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from functools import cache +from pathlib import Path +from typing import Any, Final, cast + +from opentelemetry import metrics, propagate, trace +from opentelemetry.trace import Status, StatusCode, format_span_id, format_trace_id + +from src_py_lib.utils.config import Config, config_field + +OPEN_TELEMETRY_HELP_GROUP: Final[str] = "OpenTelemetry" +OTEL_ENABLED: Final[str] = "OTEL_ENABLED" +OTEL_SDK_DISABLED: Final[str] = "OTEL_SDK_DISABLED" +OTEL_SERVICE_NAME: Final[str] = "OTEL_SERVICE_NAME" +OTEL_RESOURCE_ATTRIBUTES: Final[str] = "OTEL_RESOURCE_ATTRIBUTES" +OTEL_EXPORTER_OTLP_ENDPOINT: Final[str] = "OTEL_EXPORTER_OTLP_ENDPOINT" +OTEL_EXPORTER_OTLP_HEADERS: Final[str] = "OTEL_EXPORTER_OTLP_HEADERS" +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: Final[str] = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" +OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: Final[str] = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" + +_TRACER_NAME: Final[str] = "src_py_lib" +_METER_NAME: Final[str] = "src_py_lib" +_TRACEPARENT_HEADER: Final[str] = "traceparent" + + +class OpenTelemetryConfig(Config): + """Config fields for OpenTelemetry CLI and environment options.""" + + open_telemetry: bool = config_field( + default=False, + env_var=OTEL_ENABLED, + cli_flag="--otel", + cli_action="store_true", + help="Enable OpenTelemetry OTLP/HTTP traces and metrics", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + open_telemetry_service_name: str | None = config_field( + default=None, + env_var=OTEL_SERVICE_NAME, + cli_flag="--otel-service-name", + metavar="NAME", + help="OpenTelemetry service name; maps to OTEL_SERVICE_NAME", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + open_telemetry_resource_attributes: str | None = config_field( + default=None, + env_var=OTEL_RESOURCE_ATTRIBUTES, + cli_flag="--otel-resource-attributes", + metavar="KEY=VALUE,...", + help="Resource attributes; maps to OTEL_RESOURCE_ATTRIBUTES", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + open_telemetry_exporter_otlp_endpoint: str | None = config_field( + default=None, + env_var=OTEL_EXPORTER_OTLP_ENDPOINT, + cli_flag="--otel-exporter-otlp-endpoint", + metavar="URL", + help="OTLP/HTTP endpoint; maps to OTEL_EXPORTER_OTLP_ENDPOINT", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + open_telemetry_exporter_otlp_headers: str | None = config_field( + default=None, + env_var=OTEL_EXPORTER_OTLP_HEADERS, + cli_flag="--otel-exporter-otlp-headers", + metavar="KEY=VALUE,...", + help="OTLP headers; maps to OTEL_EXPORTER_OTLP_HEADERS", + help_group=OPEN_TELEMETRY_HELP_GROUP, + secret=True, + ) + open_telemetry_exporter_otlp_traces_endpoint: str | None = config_field( + default=None, + env_var=OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + cli_flag="--otel-exporter-otlp-traces-endpoint", + metavar="URL", + help="OTLP/HTTP traces endpoint; maps to OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + open_telemetry_exporter_otlp_metrics_endpoint: str | None = config_field( + default=None, + env_var=OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + cli_flag="--otel-exporter-otlp-metrics-endpoint", + metavar="URL", + help="OTLP/HTTP metrics endpoint; maps to OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + help_group=OPEN_TELEMETRY_HELP_GROUP, + ) + + +@dataclass(frozen=True) +class OpenTelemetrySettings: + """Runtime OpenTelemetry settings resolved from config and call-site needs.""" + + enabled: bool = False + force_traces: bool = False + service_name: str | None = None + resource_attributes: str | None = None + exporter_otlp_endpoint: str | None = None + exporter_otlp_headers: str | None = None + exporter_otlp_traces_endpoint: str | None = None + exporter_otlp_metrics_endpoint: str | None = None + + +@dataclass(frozen=True) +class OpenTelemetryRuntime: + """OpenTelemetry provider handles configured for the current process.""" + + configured: bool + exporting: bool + tracer_provider: object | None = None + meter_provider: object | None = None + + def force_flush(self, timeout_millis: int = 30_000) -> None: + """Flush configured providers if they expose a force_flush method.""" + for provider in (self.tracer_provider, self.meter_provider): + force_flush = getattr(provider, "force_flush", None) + if callable(force_flush): + force_flush(timeout_millis=timeout_millis) + + +class OpenTelemetrySetupError(RuntimeError): + """Raised when OpenTelemetry was requested but optional packages are missing.""" + + +def open_telemetry_settings_from_config( + config: object | None = None, + *, + enabled: bool | None = None, + force_traces: bool = False, + service_name: str | None = None, +) -> OpenTelemetrySettings: + """Return OpenTelemetry settings from shared config fields.""" + resolved_enabled = ( + _config_value(config, "open_telemetry", False) if enabled is None else enabled + ) + return OpenTelemetrySettings( + enabled=bool(resolved_enabled), + force_traces=force_traces, + service_name=service_name or _optional_string(config, "open_telemetry_service_name"), + resource_attributes=_optional_string(config, "open_telemetry_resource_attributes"), + exporter_otlp_endpoint=_optional_string(config, "open_telemetry_exporter_otlp_endpoint"), + exporter_otlp_headers=_optional_string(config, "open_telemetry_exporter_otlp_headers"), + exporter_otlp_traces_endpoint=_optional_string( + config, "open_telemetry_exporter_otlp_traces_endpoint" + ), + exporter_otlp_metrics_endpoint=_optional_string( + config, "open_telemetry_exporter_otlp_metrics_endpoint" + ), + ) + + +def configure_open_telemetry(settings: OpenTelemetrySettings) -> OpenTelemetryRuntime: + """Configure OpenTelemetry providers for traces and metrics. + + `enabled=True` exports OTLP/HTTP traces and metrics. `force_traces=True` + installs SDK providers without exporters so W3C propagation still has real + trace/span identifiers, useful for Sourcegraph debug trace capture. + """ + if _otel_sdk_disabled() or not (settings.enabled or settings.force_traces): + return OpenTelemetryRuntime(configured=False, exporting=False) + + resource = _resource(settings) + tracer_provider = _configure_traces(settings, resource) + meter_provider = _configure_metrics(settings, resource) + return OpenTelemetryRuntime( + configured=True, + exporting=settings.enabled, + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + + +def inject_current_trace_context(headers: MutableMapping[str, str]) -> None: + """Inject the active W3C trace context into outbound request headers.""" + if any(name.lower() == _TRACEPARENT_HEADER for name in headers): + return + propagate.inject(headers) + + +def current_traceparent_header() -> str | None: + """Return the active W3C traceparent header value, if a valid span exists.""" + carrier: dict[str, str] = {} + propagate.inject(carrier) + return carrier.get(_TRACEPARENT_HEADER) + + +def traceparent_fields(value: str | None) -> dict[str, str]: + """Return trace/span identifiers extracted from a W3C traceparent header.""" + if not value: + return {} + context = propagate.extract({_TRACEPARENT_HEADER: value}) + span_context = trace.get_current_span(context).get_span_context() + if not span_context.is_valid: + return {} + return { + "trace_id": format_trace_id(span_context.trace_id), + "span_id": format_span_id(span_context.span_id), + } + + +@contextmanager +def open_telemetry_span(name: str, fields: Mapping[str, object] | None = None) -> Any: + """Start an OpenTelemetry span and attach safe attributes.""" + with trace.get_tracer(_TRACER_NAME).start_as_current_span(name) as span: + if fields: + for key, value in span_attributes(fields).items(): + span.set_attribute(key, value) + yield span + + +def current_trace_fields(parent_span_id: str | None = None) -> dict[str, str]: + """Return log-friendly trace/span identifiers for the active span.""" + span_context = trace.get_current_span().get_span_context() + if not span_context.is_valid: + return {} + fields = { + "trace": format_trace_id(span_context.trace_id), + "span": format_span_id(span_context.span_id), + } + if parent_span_id: + fields["parent_span"] = parent_span_id + return fields + + +def current_span_id() -> str | None: + """Return the current span identifier, if there is a valid active span.""" + span_context = trace.get_current_span().get_span_context() + return format_span_id(span_context.span_id) if span_context.is_valid else None + + +def set_current_span_attributes(fields: Mapping[str, object]) -> None: + """Set log-safe attributes on the active span.""" + span = trace.get_current_span() + if not span.is_recording(): + return + for key, value in span_attributes(fields).items(): + span.set_attribute(key, value) + + +def add_span_event(name: str, fields: Mapping[str, object]) -> None: + """Add an event to the active span when recording is enabled.""" + span = trace.get_current_span() + if span.is_recording(): + span.add_event(name, attributes=span_attributes(fields)) + + +def mark_current_span_error(description: str) -> None: + """Mark the active span as failed.""" + span = trace.get_current_span() + if span.is_recording(): + span.set_status(Status(StatusCode.ERROR, description)) + + +def record_http_client_metrics( + *, + method: str, + url: str, + duration_seconds: float, + request_bytes: int, + response_bytes: int = 0, + status_code: int | None = None, + transport_error: bool = False, +) -> None: + """Record one HTTP client attempt with OpenTelemetry metrics.""" + attributes = _http_metric_attributes( + method=method, + url=url, + status_code=status_code, + transport_error=transport_error, + ) + instruments = _http_metric_instruments() + instruments["request_count"].add(1, attributes) + instruments["duration"].record(max(duration_seconds, 0.0), attributes) + instruments["request_bytes"].record(max(request_bytes, 0), attributes) + instruments["response_bytes"].record(max(response_bytes, 0), attributes) + if transport_error: + instruments["transport_error_count"].add(1, attributes) + + +def record_http_client_retry(*, method: str, url: str, status_code: int | None = None) -> None: + """Record one HTTP client retry with OpenTelemetry metrics.""" + _http_metric_instruments()["retry_count"].add( + 1, + _http_metric_attributes(method=method, url=url, status_code=status_code), + ) + + +def span_attributes(fields: Mapping[str, object]) -> dict[str, Any]: + """Return OpenTelemetry-safe attributes from structured log fields.""" + attributes: dict[str, Any] = {} + for key, value in fields.items(): + attribute_value = _attribute_value(value) + if attribute_value is not None: + attributes[key] = attribute_value + return attributes + + +def _configure_traces(settings: OpenTelemetrySettings, resource: object) -> object: + provider = trace.get_tracer_provider() + if not _is_default_provider(provider): + return provider + + tracer_provider_class = _required_symbol( + "opentelemetry.sdk.trace", + "TracerProvider", + ) + tracer_provider = tracer_provider_class(resource=resource) + if settings.enabled: + exporter_class = _required_symbol( + "opentelemetry.exporter.otlp.proto.http.trace_exporter", + "OTLPSpanExporter", + ) + processor_class = _required_symbol( + "opentelemetry.sdk.trace.export", + "BatchSpanProcessor", + ) + exporter = exporter_class( + endpoint=settings.exporter_otlp_traces_endpoint or settings.exporter_otlp_endpoint, + headers=_headers(settings.exporter_otlp_headers), + ) + tracer_provider.add_span_processor(processor_class(exporter)) + trace.set_tracer_provider(tracer_provider) + return tracer_provider + + +def _configure_metrics(settings: OpenTelemetrySettings, resource: object) -> object: + provider = metrics.get_meter_provider() + if not _is_default_provider(provider): + return provider + + meter_provider_class = _required_symbol( + "opentelemetry.sdk.metrics", + "MeterProvider", + ) + readers: list[object] = [] + if settings.enabled: + exporter_class = _required_symbol( + "opentelemetry.exporter.otlp.proto.http.metric_exporter", + "OTLPMetricExporter", + ) + reader_class = _required_symbol( + "opentelemetry.sdk.metrics.export", + "PeriodicExportingMetricReader", + ) + exporter = exporter_class( + endpoint=settings.exporter_otlp_metrics_endpoint or settings.exporter_otlp_endpoint, + headers=_headers(settings.exporter_otlp_headers), + ) + readers.append(reader_class(exporter)) + meter_provider = meter_provider_class(resource=resource, metric_readers=readers) + metrics.set_meter_provider(meter_provider) + return meter_provider + + +def _resource(settings: OpenTelemetrySettings) -> object: + resource_class = _required_symbol("opentelemetry.sdk.resources", "Resource") + attributes = _resource_attributes(settings.resource_attributes) + if settings.service_name: + attributes["service.name"] = settings.service_name + return resource_class.create(attributes) + + +def _http_metric_instruments() -> dict[str, Any]: + return _http_metric_instruments_for_provider(id(metrics.get_meter_provider())) + + +@cache +def _http_metric_instruments_for_provider(_provider_id: int) -> dict[str, Any]: + meter = metrics.get_meter(_METER_NAME) + return { + "request_count": meter.create_counter( + "http.client.request.count", + unit="{request}", + description="HTTP client attempts", + ), + "retry_count": meter.create_counter( + "src_py_lib.http.client.retry.count", + unit="{retry}", + description="HTTP client retries", + ), + "transport_error_count": meter.create_counter( + "src_py_lib.http.client.transport_error.count", + unit="{error}", + description="HTTP client transport errors", + ), + "duration": meter.create_histogram( + "http.client.request.duration", + unit="s", + description="HTTP client attempt duration", + ), + "request_bytes": meter.create_histogram( + "http.client.request.body.size", + unit="By", + description="HTTP request body size", + ), + "response_bytes": meter.create_histogram( + "http.client.response.body.size", + unit="By", + description="HTTP response body size", + ), + } + + +def _http_metric_attributes( + *, + method: str, + url: str, + status_code: int | None = None, + transport_error: bool = False, +) -> dict[str, object]: + split_url = urllib.parse.urlsplit(url) + attributes: dict[str, object] = { + "http.request.method": method.upper(), + "server.address": split_url.hostname or "", + "url.scheme": split_url.scheme, + "error.type": "transport" if transport_error else "", + } + if split_url.port is not None: + attributes["server.port"] = split_url.port + if status_code is not None: + attributes["http.response.status_code"] = status_code + return attributes + + +def _attribute_value(value: object) -> Any: + if value is None: + return None + if isinstance(value, str | bool | int | float): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray | str): + values = [_attribute_value(entry) for entry in cast(Sequence[object], value)] + if all(isinstance(entry, str | bool | int | float) for entry in values): + return values + if isinstance(value, Mapping): + return json.dumps(value, default=str, sort_keys=True) + return str(cast(object, value)) + + +def _resource_attributes(value: str | None) -> dict[str, str]: + attributes: dict[str, str] = {} + for part in (value or "").split(","): + key, separator, raw_value = part.strip().partition("=") + if separator and key: + attributes[key] = raw_value + return attributes + + +def _headers(value: str | None) -> dict[str, str] | None: + if not value: + return None + headers: dict[str, str] = {} + for part in value.split(","): + key, separator, raw_value = part.strip().partition("=") + if separator and key: + headers[key] = urllib.parse.unquote(raw_value) + return headers + + +def _required_symbol(module_name: str, symbol_name: str) -> Any: + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exception: + raise OpenTelemetrySetupError( + "OpenTelemetry export requires the src-py-lib[otel] extra. " + "Install it or disable --otel." + ) from exception + return getattr(module, symbol_name) + + +def _is_default_provider(provider: object) -> bool: + return provider.__class__.__name__.startswith("Proxy") + + +def _otel_sdk_disabled() -> bool: + return os.environ.get(OTEL_SDK_DISABLED, "").strip().lower() in {"1", "true", "yes"} + + +def _config_value(config: object | None, name: str, default: object) -> object: + return getattr(config, name, default) if config is not None else default + + +def _optional_string(config: object | None, name: str) -> str | None: + value = _config_value(config, name, None) + return value if isinstance(value, str) and value else None diff --git a/tests/test_import.py b/tests/test_import.py index 3868380..0fd1ebd 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -21,6 +21,8 @@ def test_root_public_api_exports_common_entrypoints(self) -> None: self.assertIsNotNone(src_py_lib.LinearClientConfig) self.assertIsNotNone(src_py_lib.LoggingConfig) self.assertIsNotNone(src_py_lib.LoggingSettings) + self.assertIsNotNone(src_py_lib.OpenTelemetryConfig) + self.assertIsNotNone(src_py_lib.OpenTelemetrySettings) self.assertIsNotNone(src_py_lib.resolve_log_level_name) self.assertIsNotNone(src_py_lib.SlackClient) self.assertIsNotNone(src_py_lib.SlackPacer) @@ -29,21 +31,24 @@ def test_root_public_api_exports_common_entrypoints(self) -> None: self.assertIsNotNone(src_py_lib.config_field) self.assertIsNotNone(src_py_lib.config_field_names) self.assertIsNotNone(src_py_lib.config_help_formatter) + self.assertIsNotNone(src_py_lib.configure_open_telemetry) self.assertIsNotNone(src_py_lib.gh_cli_token) self.assertIsNotNone(src_py_lib.gcloud_adc_access_token) self.assertIsNotNone(src_py_lib.info) self.assertIsNotNone(src_py_lib.json_dicts) self.assertIsNotNone(src_py_lib.json_str) - self.assertIsNotNone(src_py_lib.log) + self.assertIsNotNone(src_py_lib.log_event) self.assertIsNotNone(src_py_lib.logging) self.assertIsNotNone(src_py_lib.logging_settings_from_config) self.assertIsNotNone(src_py_lib.linear_client_from_config) self.assertIsNotNone(src_py_lib.load_json_cache) self.assertIsNotNone(src_py_lib.normalize_sourcegraph_endpoint) + self.assertIsNotNone(src_py_lib.open_telemetry_settings_from_config) self.assertIsNotNone(src_py_lib.parse_args) self.assertIsNotNone(src_py_lib.quota_project_from_adc) self.assertIsNotNone(src_py_lib.save_json_cache) self.assertIsNotNone(src_py_lib.slack_client_from_config) + self.assertIsNotNone(src_py_lib.span) self.assertIsNotNone(src_py_lib.sourcegraph_client_from_config) self.assertIsNotNone(src_py_lib.stream_connection_nodes) self.assertIsNotNone(src_py_lib.write_tsv) diff --git a/tests/test_logging_http_clients.py b/tests/test_logging_http_clients.py index a9e445d..f7eee2b 100644 --- a/tests/test_logging_http_clients.py +++ b/tests/test_logging_http_clients.py @@ -69,12 +69,12 @@ debug, default_log_file, error, - event, info, - log, log_context, + log_event, logging_settings_from_config, resolve_log_level_name, + span, startup_event, warning, ) @@ -930,7 +930,7 @@ def test_log_and_level_helpers_use_string_levels(self) -> None: ) ) try: - log("bogus", "fallback_info", logger_name=logger_name) + log_event("bogus", "fallback_info", logger_name=logger_name) warning("warning_event", logger_name=logger_name) error("error_event", logger_name=logger_name) critical("critical_event", logger_name=logger_name) @@ -1040,7 +1040,8 @@ def test_structured_log_file_includes_context_and_sanitized_terminal_omits_event self.assertEqual(rows[-1]["command"], "unit-test") self.assertEqual(rows[-1]["answer"], 42) - def test_event_context_adds_trace_and_span_fields(self) -> None: + def test_span_context_adds_trace_and_span_fields(self) -> None: + src.configure_open_telemetry(src.OpenTelemetrySettings(force_traces=True)) with tempfile.TemporaryDirectory() as directory: log_file = Path(directory) / "events.json" logger_name = "src_py_lib_test_traces" @@ -1053,9 +1054,9 @@ def test_event_context_adds_trace_and_span_fields(self) -> None: ) ) try: - with event("outer", logger_name=logger_name): + with span("outer", logger_name=logger_name): info("inside", logger_name=logger_name, answer=42) - with event("inner", logger_name=logger_name): + with span("inner", logger_name=logger_name): logging.getLogger(logger_name).info("inside nested span") finally: logger = logging.getLogger(logger_name) @@ -1129,22 +1130,22 @@ def test_event_context_adds_trace_and_span_fields(self) -> None: self.assertEqual(inner_log["span"], inner_start["span"]) self.assertEqual(inner_log["parent_span"], outer_start["span"]) - def test_trace_context_helpers_generate_w3c_traceparent_headers(self) -> None: - root = src.new_trace_context() - child = root.child() + def test_otel_helpers_return_current_w3c_traceparent_fields(self) -> None: + src.configure_open_telemetry(src.OpenTelemetrySettings(force_traces=True)) - self.assertEqual(len(root.trace_id), 32) - self.assertEqual(len(root.span_id), 16) - self.assertEqual(child.trace_id, root.trace_id) - self.assertEqual(child.parent_span_id, root.span_id) - self.assertRegex(root.traceparent(), r"^00-[0-9a-f]{32}-[0-9a-f]{16}-01$") - self.assertEqual(src.trace_context_from_traceparent(root.traceparent()), root) + with span("traceparent_test"): + traceparent = src.current_traceparent_header() + self.assertIsNotNone(traceparent) + assert traceparent is not None + traceparent_parts = traceparent.split("-") - with src.trace_context(root): - self.assertEqual(src.current_trace_context(), root) - self.assertEqual(src.traceparent_header(), root.traceparent()) + self.assertRegex(traceparent, r"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$") + self.assertEqual( + src.traceparent_fields(traceparent), + {"trace_id": traceparent_parts[1], "span_id": traceparent_parts[2]}, + ) - def test_event_can_lower_start_level_and_omit_success_status(self) -> None: + def test_span_can_lower_start_level_and_omit_success_status(self) -> None: with tempfile.TemporaryDirectory() as directory: log_file = Path(directory) / "events.json" logger_name = "src_py_lib_test_quiet_event" @@ -1158,7 +1159,7 @@ def test_event_can_lower_start_level_and_omit_success_status(self) -> None: ) ) try: - with event( + with span( "quiet_start", logger_name=logger_name, level="info", @@ -1617,7 +1618,8 @@ def test_sourcegraph_client_validate_queries_current_user(self) -> None: self.assertIn("SourcegraphClientValidate", str(body.get("query") or "")) self.assertIn("currentUser", str(body.get("query") or "")) - def test_sourcegraph_trace_mode_records_and_streams_jaeger_summary(self) -> None: + def test_sourcegraph_debug_trace_mode_records_and_streams_jaeger_summary(self) -> None: + src.configure_open_telemetry(src.OpenTelemetrySettings(force_traces=True)) trace_id = "1" * 32 span_id = "2" * 16 requests: list[httpx.Request] = [] @@ -1664,11 +1666,10 @@ def handler(request: httpx.Request) -> httpx.Response: "https://sourcegraph.example.com/", "token", http=HTTPClient(max_attempts=1, transport=httpx.MockTransport(handler)), - trace=True, + fetch_sg_traces=True, ) - root_context = src.TraceContext(trace_id="3" * 32, span_id="4" * 16) - with src.trace_context(root_context): + with span("sourcegraph_test"): self.assertEqual( client.graphql("query Viewer { currentUser { username } }", follow_pages=False), {"currentUser": {"username": "alice"}}, @@ -1680,11 +1681,10 @@ def handler(request: httpx.Request) -> httpx.Response: traceparent = requests[0].headers["traceparent"] traceparent_parts = traceparent.split("-") self.assertEqual(requests[0].headers["x-sourcegraph-request-trace"], "true") - self.assertRegex(traceparent, r"^00-[0-9a-f]{32}-[0-9a-f]{16}-01$") - self.assertEqual(traceparent_parts[1], root_context.trace_id) + self.assertRegex(traceparent, r"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$") self.assertEqual(traces[0].trace_id, trace_id) self.assertEqual(traces[0].span_id, span_id) - self.assertEqual(traces[0].parent_trace_id, root_context.trace_id) + self.assertEqual(traces[0].parent_trace_id, traceparent_parts[1]) self.assertEqual(traces[0].parent_span_id, traceparent_parts[2]) self.assertEqual(len(summaries), 1) self.assertTrue(summaries[0].jaeger_found) diff --git a/uv.lock b/uv.lock index aedd26b..d7b0af6 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -88,6 +189,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -227,6 +424,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.16" @@ -257,12 +469,21 @@ name = "src-py-lib" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "python-dotenv" }, ] +[package.optional-dependencies] +otel = [ + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] + [package.dev-dependencies] dev = [ + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "pyright" }, { name = "ruff" }, ] @@ -270,12 +491,18 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "opentelemetry-api", specifier = ">=1.38.0,<2" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.38.0,<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.38.0,<2" }, { name = "pydantic", specifier = ">=2.13.4,<3" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, ] +provides-extras = ["otel"] [package.metadata.requires-dev] dev = [ + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.38.0,<2" }, + { name = "opentelemetry-sdk", specifier = ">=1.38.0,<2" }, { name = "pyright", specifier = ">=1.1.410" }, { name = "ruff", specifier = ">=0.15.16" }, ] @@ -300,3 +527,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]