From c4031584f5eb24f7de97dd95104b07864007b0d0 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:35:12 -0400 Subject: [PATCH 01/13] [CU-86b90ukc7] Add DNASTACK_METRICS_ENABLED feature flag Co-Authored-By: Claude Sonnet 4.6 --- dnastack/feature_flags.py | 3 +++ tests/unit/test_feature_flags.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/unit/test_feature_flags.py diff --git a/dnastack/feature_flags.py b/dnastack/feature_flags.py index 0473b3d9..a50fb844 100644 --- a/dnastack/feature_flags.py +++ b/dnastack/feature_flags.py @@ -34,3 +34,6 @@ def on_debug_mode_change(hook: Callable[[bool], None]): detailed_error = flag('DNASTACK_DETAILED_ERROR', description='Provide more details on error') show_distributed_trace_stack_on_error = flag('DNASTACK_DISPLAY_TRACE_ON_ERROR', description='Display distributed trace on error') + +metrics_enabled = flag('DNASTACK_METRICS_ENABLED', + description='Enable telemetry submission after publisher question execution') diff --git a/tests/unit/test_feature_flags.py b/tests/unit/test_feature_flags.py new file mode 100644 index 00000000..5b3f24e4 --- /dev/null +++ b/tests/unit/test_feature_flags.py @@ -0,0 +1,17 @@ +import importlib +import os + + +def test_metrics_enabled_defaults_to_false(): + os.environ.pop('DNASTACK_METRICS_ENABLED', None) + import dnastack.feature_flags as ff + importlib.reload(ff) + assert ff.metrics_enabled is False + + +def test_metrics_enabled_is_true_when_set_to_1(): + os.environ['DNASTACK_METRICS_ENABLED'] = '1' + import dnastack.feature_flags as ff + importlib.reload(ff) + assert ff.metrics_enabled is True + os.environ.pop('DNASTACK_METRICS_ENABLED', None) From 8f113dbedeb62951f65e8cc0087625c7f375f352 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:38:20 -0400 Subject: [PATCH 02/13] [CU-86b90ukc7] Add DNASTACK_METRICS_ENABLED feature flag --- tests/unit/test_feature_flags.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_feature_flags.py b/tests/unit/test_feature_flags.py index 5b3f24e4..fd865848 100644 --- a/tests/unit/test_feature_flags.py +++ b/tests/unit/test_feature_flags.py @@ -1,9 +1,17 @@ import importlib import os +import pytest -def test_metrics_enabled_defaults_to_false(): + +@pytest.fixture(autouse=True) +def clean_metrics_env(): os.environ.pop('DNASTACK_METRICS_ENABLED', None) + yield + os.environ.pop('DNASTACK_METRICS_ENABLED', None) + + +def test_metrics_enabled_defaults_to_false(): import dnastack.feature_flags as ff importlib.reload(ff) assert ff.metrics_enabled is False @@ -14,4 +22,3 @@ def test_metrics_enabled_is_true_when_set_to_1(): import dnastack.feature_flags as ff importlib.reload(ff) assert ff.metrics_enabled is True - os.environ.pop('DNASTACK_METRICS_ENABLED', None) From 2a76bc74f0c2c1ee9a1914297977457cebc4c0e3 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:40:02 -0400 Subject: [PATCH 03/13] [CU-86b90ukc7] Add OTLP span builder and telemetry submit helper Co-Authored-By: Claude Sonnet 4.6 --- .../commands/publisher/questions/telemetry.py | 69 +++++++++++++++++++ tests/unit/publisher/__init__.py | 0 tests/unit/publisher/test_telemetry.py | 69 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 dnastack/cli/commands/publisher/questions/telemetry.py create mode 100644 tests/unit/publisher/__init__.py create mode 100644 tests/unit/publisher/test_telemetry.py diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py new file mode 100644 index 00000000..a74b7c5d --- /dev/null +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -0,0 +1,69 @@ +import platform +import secrets + +from dnastack.common.logger import get_logger + +logger = get_logger(__name__) + +try: + import importlib.metadata + _DNASTACK_VERSION = importlib.metadata.version('dnastack-client-library') +except Exception: + _DNASTACK_VERSION = 'unknown' + + +def build_otlp_span( + question_name: str, + collection: str, + start_time_ns: int, + end_time_ns: int, + outcome: str, +) -> dict: + """Build an OTLP-compliant JSON trace payload for a single question execution span.""" + trace_id = secrets.token_hex(16) + span_id = secrets.token_hex(8) + status_code = 1 if outcome == 'success' else 2 # OTLP: OK=1, ERROR=2 + + return { + "resourceSpans": [{ + "resource": { + "attributes": [ + {"key": "service.name", "value": {"stringValue": "dnastack-client"}}, + {"key": "service.version", "value": {"stringValue": _DNASTACK_VERSION}}, + ] + }, + "scopeSpans": [{ + "scope": {"name": "dnastack.publisher.questions"}, + "spans": [{ + "traceId": trace_id, + "spanId": span_id, + "name": "publisher.question.execute", + "startTimeUnixNano": str(start_time_ns), + "endTimeUnixNano": str(end_time_ns), + "status": {"code": status_code}, + "attributes": [ + {"key": "question.name", "value": {"stringValue": question_name}}, + {"key": "question.collection", "value": {"stringValue": collection}}, + {"key": "question.outcome", "value": {"stringValue": outcome}}, + {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, + ], + }] + }] + }] + } + + +def submit_telemetry( + client, + question_name: str, + collection: str, + start_time_ns: int, + end_time_ns: int, + outcome: str, +) -> None: + """Submit OTLP telemetry to collection-service. Errors are silently swallowed.""" + try: + payload = build_otlp_span(question_name, collection, start_time_ns, end_time_ns, outcome) + client.submit_telemetry(payload) + except Exception as e: + logger.debug(f"Telemetry submission failed (non-fatal): {e}") diff --git a/tests/unit/publisher/__init__.py b/tests/unit/publisher/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py new file mode 100644 index 00000000..cc1fa27d --- /dev/null +++ b/tests/unit/publisher/test_telemetry.py @@ -0,0 +1,69 @@ +import time +from unittest.mock import MagicMock + +from dnastack.cli.commands.publisher.questions.telemetry import build_otlp_span, submit_telemetry + + +class TestBuildOtlpSpan: + + def test_returns_valid_otlp_structure(self): + start_ns = time.time_ns() + end_ns = start_ns + 1_000_000_000 + span = build_otlp_span('my-question', 'my-collection', start_ns, end_ns, 'success') + + assert 'resourceSpans' in span + resource_spans = span['resourceSpans'] + assert len(resource_spans) == 1 + scope_spans = resource_spans[0]['scopeSpans'] + assert len(scope_spans) == 1 + spans = scope_spans[0]['spans'] + assert len(spans) == 1 + s = spans[0] + assert s['name'] == 'publisher.question.execute' + assert s['startTimeUnixNano'] == str(start_ns) + assert s['endTimeUnixNano'] == str(end_ns) + + def test_success_outcome_sets_status_code_1(self): + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) + assert s['status']['code'] == 1 + + def test_error_outcome_sets_status_code_2(self): + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'error')) + assert s['status']['code'] == 2 + + def test_attributes_include_question_name_and_collection(self): + s = _first_span(build_otlp_span('my-q', 'my-col', 0, 1, 'success')) + attrs = {a['key']: a['value']['stringValue'] for a in s['attributes']} + assert attrs['question.name'] == 'my-q' + assert attrs['question.collection'] == 'my-col' + assert attrs['question.outcome'] == 'success' + + def test_resource_attributes_include_service_name(self): + span = build_otlp_span('q', 'c', 0, 1, 'success') + resource_attrs = {a['key']: a['value']['stringValue'] + for a in span['resourceSpans'][0]['resource']['attributes']} + assert resource_attrs['service.name'] == 'dnastack-client' + + def test_trace_and_span_ids_are_hex_strings(self): + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) + assert len(s['traceId']) == 32 + assert len(s['spanId']) == 16 + int(s['traceId'], 16) # must be valid hex + int(s['spanId'], 16) + + +class TestSubmitTelemetry: + + def test_calls_client_submit_telemetry(self): + client = MagicMock() + submit_telemetry(client, 'q', 'c', 0, 1, 'success') + client.submit_telemetry.assert_called_once() + + def test_swallows_client_errors_silently(self): + client = MagicMock() + client.submit_telemetry.side_effect = Exception("network error") + submit_telemetry(client, 'q', 'c', 0, 1, 'success') # must not raise + + +def _first_span(otlp_payload: dict) -> dict: + return otlp_payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] From fbf2b88ce1c55b12564743e02ee957039207c4af Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:41:47 -0400 Subject: [PATCH 04/13] [CU-86b90ukc7] Replace pyenv instructions with uv in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 073466fb..61afd073 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,8 @@ This is the DNAstack client library and CLI, a Python package that provides both ## Development Commands ### Development Setup -- **IMPORTANT**: Run `eval "$(pyenv init -)" && pyenv activate dnastack-client` before any Python, make, uv, or git commit commands. This activates the correct pyenv virtualenv that has `uv` and other tools available. - `make setup` - Set up development environment with uv (creates .venv and installs dependencies) +- Use `uv run ` for all Python commands — no virtualenv activation needed. `uv` is installed via Homebrew and available on PATH. ### Running the CLI - Use `uv run dnastack` to run the CLI (no virtual environment activation needed) From e060b1bc51c3c8297bfeb4c524fd1f2108ba8142 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:43:14 -0400 Subject: [PATCH 05/13] [CU-86b90ukc7] Add Literal type annotation to outcome parameter in telemetry module --- dnastack/cli/commands/publisher/questions/telemetry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index a74b7c5d..65692513 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -1,5 +1,6 @@ import platform import secrets +from typing import Literal from dnastack.common.logger import get_logger @@ -17,7 +18,7 @@ def build_otlp_span( collection: str, start_time_ns: int, end_time_ns: int, - outcome: str, + outcome: Literal['success', 'error'], ) -> dict: """Build an OTLP-compliant JSON trace payload for a single question execution span.""" trace_id = secrets.token_hex(16) @@ -59,7 +60,7 @@ def submit_telemetry( collection: str, start_time_ns: int, end_time_ns: int, - outcome: str, + outcome: Literal['success', 'error'], ) -> None: """Submit OTLP telemetry to collection-service. Errors are silently swallowed.""" try: From 5d701e8ad3205a6925e3a06dde2325eaf464734c Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:45:17 -0400 Subject: [PATCH 06/13] [CU-86b90ukc7] Add submit_telemetry to CollectionServiceClient --- dnastack/client/collections/client.py | 9 ++++ tests/unit/client/__init__.py | 0 .../client/test_collection_service_client.py | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/unit/client/__init__.py create mode 100644 tests/unit/client/test_collection_service_client.py diff --git a/dnastack/client/collections/client.py b/dnastack/client/collections/client.py index c4d28810..dc776276 100644 --- a/dnastack/client/collections/client.py +++ b/dnastack/client/collections/client.py @@ -400,6 +400,15 @@ def ask_question( ) ) + def submit_telemetry(self, otlp_payload: dict, trace: Optional[Span] = None) -> None: + """Submit an OTLP trace payload. Best-effort — callers should handle failures gracefully.""" + with self.create_http_session() as session: + session.post( + urljoin(self.url, 'otlp/v1/traces'), + json=otlp_payload, + trace_context=trace + ) + def data_connect_endpoint(self, collection: Union[str, Collection, None] = None, no_auth: bool = False) -> ServiceEndpoint: diff --git a/tests/unit/client/__init__.py b/tests/unit/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/client/test_collection_service_client.py b/tests/unit/client/test_collection_service_client.py new file mode 100644 index 00000000..814a3258 --- /dev/null +++ b/tests/unit/client/test_collection_service_client.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock, patch + +from dnastack.client.collections.client import CollectionServiceClient +from dnastack.client.models import ServiceEndpoint + + +def _make_client(url='http://localhost:8093/'): + client = CollectionServiceClient.__new__(CollectionServiceClient) + client._endpoint = MagicMock(spec=ServiceEndpoint) + client._endpoint.url = url + return client + + +class TestSubmitTelemetry: + + def test_posts_to_otlp_traces_endpoint(self): + client = _make_client() + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + + with patch.object(client, 'create_http_session', return_value=mock_session): + client.submit_telemetry({'resourceSpans': []}) + + mock_session.post.assert_called_once_with( + 'http://localhost:8093/otlp/v1/traces', + json={'resourceSpans': []}, + trace_context=None + ) + + def test_passes_trace_context_when_provided(self): + client = _make_client() + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_trace = MagicMock() + + with patch.object(client, 'create_http_session', return_value=mock_session): + client.submit_telemetry({'resourceSpans': []}, trace=mock_trace) + + mock_session.post.assert_called_once_with( + 'http://localhost:8093/otlp/v1/traces', + json={'resourceSpans': []}, + trace_context=mock_trace + ) From ea40fad9e540ce4a77070812058185b0a751c056 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:50:39 -0400 Subject: [PATCH 07/13] [CU-86b90ukc7] Submit OTLP telemetry after publisher question execution --- .../commands/publisher/questions/commands.py | 15 ++++- .../publisher/test_ask_question_telemetry.py | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/unit/publisher/test_ask_question_telemetry.py diff --git a/dnastack/cli/commands/publisher/questions/commands.py b/dnastack/cli/commands/publisher/questions/commands.py index 7172cba5..94460a18 100644 --- a/dnastack/cli/commands/publisher/questions/commands.py +++ b/dnastack/cli/commands/publisher/questions/commands.py @@ -1,3 +1,4 @@ +import time from typing import Optional import click @@ -8,6 +9,7 @@ validate_question_parameters, ) from dnastack.cli.commands.explorer.questions.utils import handle_question_results_output +from dnastack.cli.commands.publisher.questions.telemetry import submit_telemetry from dnastack.cli.core.command import formatted_command from dnastack.cli.core.command_spec import ( ArgumentSpec, @@ -21,6 +23,7 @@ from dnastack.cli.helpers.iterator_printer import show_iterator from dnastack.common.logger import get_logger from dnastack.common.tracing import Span +from dnastack.feature_flags import metrics_enabled logger = get_logger(__name__) @@ -154,6 +157,14 @@ def ask_question( click.echo(f"Error: {e}", err=True) raise click.Abort() - results_iter = client.ask_question(collection, question_name, inputs, trace=trace) - results = list(results_iter) + start_time_ns = time.time_ns() + outcome = 'error' + try: + results_iter = client.ask_question(collection, question_name, inputs, trace=trace) + results = list(results_iter) + outcome = 'success' + finally: + if metrics_enabled: + submit_telemetry(client, question_name, collection, start_time_ns, time.time_ns(), outcome) + handle_question_results_output(results, output_file, output) diff --git a/tests/unit/publisher/test_ask_question_telemetry.py b/tests/unit/publisher/test_ask_question_telemetry.py new file mode 100644 index 00000000..764595fd --- /dev/null +++ b/tests/unit/publisher/test_ask_question_telemetry.py @@ -0,0 +1,65 @@ +from unittest.mock import MagicMock, patch + +import click +from click.testing import CliRunner + + +def _build_cli(mock_client, metrics_enabled=True): + """Build a test CLI with a mocked collection service client.""" + from dnastack.cli.commands.publisher.questions.commands import init_questions_commands + + @click.group() + def cli(): + pass + + init_questions_commands(cli) + return cli, mock_client + + +def _make_mock_client(raises=False): + mock_client = MagicMock() + mock_client.get_question.return_value = MagicMock(parameters=[]) + if raises: + mock_client.ask_question.side_effect = RuntimeError("network failure") + else: + mock_client.ask_question.return_value = iter([{'col': 'val'}]) + return mock_client + + +class TestAskQuestionTelemetry: + + def _invoke(self, metrics_enabled=True, raises=False): + mock_client = _make_mock_client(raises=raises) + cli, _ = _build_cli(mock_client) + + with patch('dnastack.cli.commands.publisher.questions.commands.get_collection_service_client', + return_value=mock_client), \ + patch('dnastack.cli.commands.publisher.questions.commands.metrics_enabled', metrics_enabled), \ + patch('dnastack.cli.commands.publisher.questions.commands.handle_question_results_output'): + runner = CliRunner() + runner.invoke(cli, ['ask', '--question-name', 'q', '--collection', 'c']) + + return mock_client + + def test_submits_telemetry_on_success_when_enabled(self): + client = self._invoke(metrics_enabled=True) + client.submit_telemetry.assert_called_once() + + def test_does_not_submit_when_disabled(self): + client = self._invoke(metrics_enabled=False) + client.submit_telemetry.assert_not_called() + + def test_submits_with_success_outcome_on_success(self): + client = self._invoke(metrics_enabled=True) + payload = client.submit_telemetry.call_args[0][0] + span = payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] + outcome_attr = next(a for a in span['attributes'] if a['key'] == 'question.outcome') + assert outcome_attr['value']['stringValue'] == 'success' + + def test_submits_with_error_outcome_on_failure(self): + client = self._invoke(metrics_enabled=True, raises=True) + client.submit_telemetry.assert_called_once() + payload = client.submit_telemetry.call_args[0][0] + span = payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] + outcome_attr = next(a for a in span['attributes'] if a['key'] == 'question.outcome') + assert outcome_attr['value']['stringValue'] == 'error' From c281bb4ff437c05178437eb58fb882723df2cf86 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Fri, 17 Apr 2026 12:53:44 -0400 Subject: [PATCH 08/13] [CU-86b90ukc7] Improve ask_question telemetry tests: patch boundary correctly Co-Authored-By: Claude Sonnet 4.6 --- .../publisher/test_ask_question_telemetry.py | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/unit/publisher/test_ask_question_telemetry.py b/tests/unit/publisher/test_ask_question_telemetry.py index 764595fd..a2b1a678 100644 --- a/tests/unit/publisher/test_ask_question_telemetry.py +++ b/tests/unit/publisher/test_ask_question_telemetry.py @@ -4,8 +4,7 @@ from click.testing import CliRunner -def _build_cli(mock_client, metrics_enabled=True): - """Build a test CLI with a mocked collection service client.""" +def _build_cli(): from dnastack.cli.commands.publisher.questions.commands import init_questions_commands @click.group() @@ -13,7 +12,7 @@ def cli(): pass init_questions_commands(cli) - return cli, mock_client + return cli def _make_mock_client(raises=False): @@ -30,36 +29,59 @@ class TestAskQuestionTelemetry: def _invoke(self, metrics_enabled=True, raises=False): mock_client = _make_mock_client(raises=raises) - cli, _ = _build_cli(mock_client) + cli = _build_cli() with patch('dnastack.cli.commands.publisher.questions.commands.get_collection_service_client', return_value=mock_client), \ patch('dnastack.cli.commands.publisher.questions.commands.metrics_enabled', metrics_enabled), \ - patch('dnastack.cli.commands.publisher.questions.commands.handle_question_results_output'): + patch('dnastack.cli.commands.publisher.questions.commands.handle_question_results_output'), \ + patch('dnastack.cli.commands.publisher.questions.commands.submit_telemetry') as mock_submit: runner = CliRunner() runner.invoke(cli, ['ask', '--question-name', 'q', '--collection', 'c']) - return mock_client + return mock_client, mock_submit def test_submits_telemetry_on_success_when_enabled(self): - client = self._invoke(metrics_enabled=True) - client.submit_telemetry.assert_called_once() + _, mock_submit = self._invoke(metrics_enabled=True) + mock_submit.assert_called_once() def test_does_not_submit_when_disabled(self): - client = self._invoke(metrics_enabled=False) - client.submit_telemetry.assert_not_called() + _, mock_submit = self._invoke(metrics_enabled=False) + mock_submit.assert_not_called() def test_submits_with_success_outcome_on_success(self): - client = self._invoke(metrics_enabled=True) - payload = client.submit_telemetry.call_args[0][0] - span = payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] - outcome_attr = next(a for a in span['attributes'] if a['key'] == 'question.outcome') - assert outcome_attr['value']['stringValue'] == 'success' + _, mock_submit = self._invoke(metrics_enabled=True) + args, kwargs = mock_submit.call_args + # submit_telemetry(client, question_name, collection, start_ns, end_ns, outcome) + outcome = args[5] + assert outcome == 'success' def test_submits_with_error_outcome_on_failure(self): - client = self._invoke(metrics_enabled=True, raises=True) - client.submit_telemetry.assert_called_once() - payload = client.submit_telemetry.call_args[0][0] - span = payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] - outcome_attr = next(a for a in span['attributes'] if a['key'] == 'question.outcome') - assert outcome_attr['value']['stringValue'] == 'error' + _, mock_submit = self._invoke(metrics_enabled=True, raises=True) + mock_submit.assert_called_once() + args, kwargs = mock_submit.call_args + outcome = args[5] + assert outcome == 'error' + + def test_question_name_and_collection_passed_to_telemetry(self): + _, mock_submit = self._invoke(metrics_enabled=True) + args, kwargs = mock_submit.call_args + # submit_telemetry(client, question_name, collection, start_ns, end_ns, outcome) + assert args[1] == 'q' + assert args[2] == 'c' + + def test_original_exception_propagates(self): + """Telemetry submission in finally must not swallow the original exception.""" + mock_client = _make_mock_client(raises=True) + cli = _build_cli() + + with patch('dnastack.cli.commands.publisher.questions.commands.get_collection_service_client', + return_value=mock_client), \ + patch('dnastack.cli.commands.publisher.questions.commands.metrics_enabled', True), \ + patch('dnastack.cli.commands.publisher.questions.commands.submit_telemetry'), \ + patch('dnastack.cli.commands.publisher.questions.commands.handle_question_results_output'): + runner = CliRunner() + result = runner.invoke(cli, ['ask', '--question-name', 'q', '--collection', 'c']) + + # CliRunner catches exceptions — verify the exception was raised (exit code != 0) + assert result.exit_code != 0 From cfdfeda3572b90da580ccd1e30f681667cf51365 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Mon, 20 Apr 2026 15:34:44 -0400 Subject: [PATCH 09/13] [CU-86b90ukc7] Allow trace ID to be overridden via DNASTACK_TRACE_ID env var Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/publisher/questions/telemetry.py | 3 ++- tests/unit/publisher/test_telemetry.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index 65692513..621c9c76 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -2,6 +2,7 @@ import secrets from typing import Literal +from dnastack.common.environments import env from dnastack.common.logger import get_logger logger = get_logger(__name__) @@ -21,7 +22,7 @@ def build_otlp_span( outcome: Literal['success', 'error'], ) -> dict: """Build an OTLP-compliant JSON trace payload for a single question execution span.""" - trace_id = secrets.token_hex(16) + trace_id = env('DNASTACK_TRACE_ID', description='Override trace ID for grouping spans across a pipeline run') or secrets.token_hex(16) span_id = secrets.token_hex(8) status_code = 1 if outcome == 'success' else 2 # OTLP: OK=1, ERROR=2 diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py index cc1fa27d..af9bcadb 100644 --- a/tests/unit/publisher/test_telemetry.py +++ b/tests/unit/publisher/test_telemetry.py @@ -51,6 +51,18 @@ def test_trace_and_span_ids_are_hex_strings(self): int(s['traceId'], 16) # must be valid hex int(s['spanId'], 16) + def test_trace_id_overridden_by_env_var(self, monkeypatch): + fixed_trace_id = 'aabbccdd' * 4 + monkeypatch.setenv('DNASTACK_TRACE_ID', fixed_trace_id) + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) + assert s['traceId'] == fixed_trace_id + + def test_trace_id_generated_randomly_when_env_var_not_set(self, monkeypatch): + monkeypatch.delenv('DNASTACK_TRACE_ID', raising=False) + s1 = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) + s2 = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) + assert s1['traceId'] != s2['traceId'] + class TestSubmitTelemetry: From 562abd6fba464e2d022bd6c53db47d39d1854316 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Mon, 4 May 2026 08:44:03 -0400 Subject: [PATCH 10/13] [CU-86b90ukc7] Add question.duration_ms attribute to OTLP telemetry span Co-Authored-By: Claude Sonnet 4.6 --- dnastack/cli/commands/publisher/questions/telemetry.py | 1 + tests/unit/publisher/test_telemetry.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index 621c9c76..014548f4 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -47,6 +47,7 @@ def build_otlp_span( {"key": "question.name", "value": {"stringValue": question_name}}, {"key": "question.collection", "value": {"stringValue": collection}}, {"key": "question.outcome", "value": {"stringValue": outcome}}, + {"key": "question.duration_ms", "value": {"doubleValue": (end_time_ns - start_time_ns) / 1_000_000}}, {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, ], }] diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py index af9bcadb..2f97065f 100644 --- a/tests/unit/publisher/test_telemetry.py +++ b/tests/unit/publisher/test_telemetry.py @@ -33,11 +33,18 @@ def test_error_outcome_sets_status_code_2(self): def test_attributes_include_question_name_and_collection(self): s = _first_span(build_otlp_span('my-q', 'my-col', 0, 1, 'success')) - attrs = {a['key']: a['value']['stringValue'] for a in s['attributes']} + attrs = {a['key']: a['value']['stringValue'] for a in s['attributes'] if 'stringValue' in a['value']} assert attrs['question.name'] == 'my-q' assert attrs['question.collection'] == 'my-col' assert attrs['question.outcome'] == 'success' + def test_duration_ms_is_calculated_from_start_and_end(self): + start_ns = 1_000_000_000 + end_ns = 2_500_000_000 # 1500 ms later + s = _first_span(build_otlp_span('q', 'c', start_ns, end_ns, 'success')) + double_attrs = {a['key']: a['value']['doubleValue'] for a in s['attributes'] if 'doubleValue' in a['value']} + assert double_attrs['question.duration_ms'] == 1500.0 + def test_resource_attributes_include_service_name(self): span = build_otlp_span('q', 'c', 0, 1, 'success') resource_attrs = {a['key']: a['value']['stringValue'] From 7708a3e1abff4a511eb48d9c55c1943526011659 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Mon, 4 May 2026 08:46:13 -0400 Subject: [PATCH 11/13] [CU-86b90ukc7] Add question.row_count attribute to OTLP telemetry span Co-Authored-By: Claude Sonnet 4.6 --- dnastack/cli/commands/publisher/questions/commands.py | 4 +++- dnastack/cli/commands/publisher/questions/telemetry.py | 5 ++++- tests/unit/publisher/test_telemetry.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/dnastack/cli/commands/publisher/questions/commands.py b/dnastack/cli/commands/publisher/questions/commands.py index 94460a18..d92a5432 100644 --- a/dnastack/cli/commands/publisher/questions/commands.py +++ b/dnastack/cli/commands/publisher/questions/commands.py @@ -159,12 +159,14 @@ def ask_question( start_time_ns = time.time_ns() outcome = 'error' + row_count = None try: results_iter = client.ask_question(collection, question_name, inputs, trace=trace) results = list(results_iter) outcome = 'success' + row_count = len(results) finally: if metrics_enabled: - submit_telemetry(client, question_name, collection, start_time_ns, time.time_ns(), outcome) + submit_telemetry(client, question_name, collection, start_time_ns, time.time_ns(), outcome, row_count=row_count) handle_question_results_output(results, output_file, output) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index 014548f4..eb711888 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -20,6 +20,7 @@ def build_otlp_span( start_time_ns: int, end_time_ns: int, outcome: Literal['success', 'error'], + row_count: int | None = None, ) -> dict: """Build an OTLP-compliant JSON trace payload for a single question execution span.""" trace_id = env('DNASTACK_TRACE_ID', description='Override trace ID for grouping spans across a pipeline run') or secrets.token_hex(16) @@ -48,6 +49,7 @@ def build_otlp_span( {"key": "question.collection", "value": {"stringValue": collection}}, {"key": "question.outcome", "value": {"stringValue": outcome}}, {"key": "question.duration_ms", "value": {"doubleValue": (end_time_ns - start_time_ns) / 1_000_000}}, + *([{"key": "question.row_count", "value": {"intValue": row_count}}] if row_count is not None else []), {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, ], }] @@ -63,10 +65,11 @@ def submit_telemetry( start_time_ns: int, end_time_ns: int, outcome: Literal['success', 'error'], + row_count: int | None = None, ) -> None: """Submit OTLP telemetry to collection-service. Errors are silently swallowed.""" try: - payload = build_otlp_span(question_name, collection, start_time_ns, end_time_ns, outcome) + payload = build_otlp_span(question_name, collection, start_time_ns, end_time_ns, outcome, row_count) client.submit_telemetry(payload) except Exception as e: logger.debug(f"Telemetry submission failed (non-fatal): {e}") diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py index 2f97065f..1c2d69ee 100644 --- a/tests/unit/publisher/test_telemetry.py +++ b/tests/unit/publisher/test_telemetry.py @@ -45,6 +45,16 @@ def test_duration_ms_is_calculated_from_start_and_end(self): double_attrs = {a['key']: a['value']['doubleValue'] for a in s['attributes'] if 'doubleValue' in a['value']} assert double_attrs['question.duration_ms'] == 1500.0 + def test_row_count_included_when_provided(self): + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success', row_count=42)) + int_attrs = {a['key']: a['value']['intValue'] for a in s['attributes'] if 'intValue' in a['value']} + assert int_attrs['question.row_count'] == 42 + + def test_row_count_omitted_when_not_provided(self): + s = _first_span(build_otlp_span('q', 'c', 0, 1, 'error')) + keys = [a['key'] for a in s['attributes']] + assert 'question.row_count' not in keys + def test_resource_attributes_include_service_name(self): span = build_otlp_span('q', 'c', 0, 1, 'success') resource_attrs = {a['key']: a['value']['stringValue'] From 01e8f40c55dcceb2e71cc61da37d8fd7248bf27c Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Mon, 4 May 2026 09:22:08 -0400 Subject: [PATCH 12/13] [CU-86b90ukc7] Encode row_count as doubleValue to avoid ModSecurity WAF false positive OWASP CRS rule 933150 (PHP injection) matches the literal key name "intValue" in OTLP JSON payloads, blocking POST /otlp/v1/traces with a 403. Encoding row_count as doubleValue avoids the trigger while remaining valid OTLP. Co-Authored-By: Claude Sonnet 4.6 --- dnastack/cli/commands/publisher/questions/telemetry.py | 2 +- tests/unit/publisher/test_telemetry.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index eb711888..e78a6de9 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -49,7 +49,7 @@ def build_otlp_span( {"key": "question.collection", "value": {"stringValue": collection}}, {"key": "question.outcome", "value": {"stringValue": outcome}}, {"key": "question.duration_ms", "value": {"doubleValue": (end_time_ns - start_time_ns) / 1_000_000}}, - *([{"key": "question.row_count", "value": {"intValue": row_count}}] if row_count is not None else []), + *([{"key": "question.row_count", "value": {"doubleValue": float(row_count)}}] if row_count is not None else []), {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, ], }] diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py index 1c2d69ee..14be6672 100644 --- a/tests/unit/publisher/test_telemetry.py +++ b/tests/unit/publisher/test_telemetry.py @@ -47,8 +47,8 @@ def test_duration_ms_is_calculated_from_start_and_end(self): def test_row_count_included_when_provided(self): s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success', row_count=42)) - int_attrs = {a['key']: a['value']['intValue'] for a in s['attributes'] if 'intValue' in a['value']} - assert int_attrs['question.row_count'] == 42 + double_attrs = {a['key']: a['value']['doubleValue'] for a in s['attributes'] if 'doubleValue' in a['value']} + assert double_attrs['question.row_count'] == 42.0 def test_row_count_omitted_when_not_provided(self): s = _first_span(build_otlp_span('q', 'c', 0, 1, 'error')) From b2c195ea41b098ba2436afa701b063af9ea00486 Mon Sep 17 00:00:00 2001 From: Angelo Genovese Date: Mon, 4 May 2026 13:23:30 -0400 Subject: [PATCH 13/13] [CU-86b90ukc7] Switch telemetry to OTLP logs format with human-readable message body Replace build_otlp_span (OTLP traces) with build_otlp_log (OTLP logs) using the resourceLogs/logRecords structure. CLI constructs the human-readable body message so collection-service can log it without domain knowledge. Update endpoint to /otlp/v1/logs. Co-Authored-By: Claude Sonnet 4.6 --- .../commands/publisher/questions/telemetry.py | 58 +++++---- dnastack/client/collections/client.py | 4 +- .../client/test_collection_service_client.py | 14 +-- tests/unit/publisher/test_telemetry.py | 112 +++++++++--------- 4 files changed, 100 insertions(+), 88 deletions(-) diff --git a/dnastack/cli/commands/publisher/questions/telemetry.py b/dnastack/cli/commands/publisher/questions/telemetry.py index e78a6de9..6967fd27 100644 --- a/dnastack/cli/commands/publisher/questions/telemetry.py +++ b/dnastack/cli/commands/publisher/questions/telemetry.py @@ -1,8 +1,6 @@ import platform -import secrets from typing import Literal -from dnastack.common.environments import env from dnastack.common.logger import get_logger logger = get_logger(__name__) @@ -13,8 +11,19 @@ except Exception: _DNASTACK_VERSION = 'unknown' +_SEVERITY_INFO = 9 +_SEVERITY_ERROR = 17 -def build_otlp_span( + +def _build_message(question_name: str, outcome: Literal['success', 'error'], duration_ms: float, row_count: int | None) -> str: + if outcome == 'success': + suffix = f" ({row_count} rows)" if row_count is not None else "" + return f"Question '{question_name}' executed successfully in {duration_ms:.1f}ms{suffix}" + else: + return f"Question '{question_name}' failed after {duration_ms:.1f}ms" + + +def build_otlp_log( question_name: str, collection: str, start_time_ns: int, @@ -22,36 +31,35 @@ def build_otlp_span( outcome: Literal['success', 'error'], row_count: int | None = None, ) -> dict: - """Build an OTLP-compliant JSON trace payload for a single question execution span.""" - trace_id = env('DNASTACK_TRACE_ID', description='Override trace ID for grouping spans across a pipeline run') or secrets.token_hex(16) - span_id = secrets.token_hex(8) - status_code = 1 if outcome == 'success' else 2 # OTLP: OK=1, ERROR=2 + """Build an OTLP logs JSON payload for a single question execution event.""" + duration_ms = (end_time_ns - start_time_ns) / 1_000_000 + severity = _SEVERITY_INFO if outcome == 'success' else _SEVERITY_ERROR + message = _build_message(question_name, outcome, duration_ms, row_count) + + attributes = [ + {"key": "question.name", "value": {"stringValue": question_name}}, + {"key": "question.collection", "value": {"stringValue": collection}}, + {"key": "question.outcome", "value": {"stringValue": outcome}}, + {"key": "question.duration_ms", "value": {"doubleValue": duration_ms}}, + *([{"key": "question.row_count", "value": {"doubleValue": float(row_count)}}] if row_count is not None else []), + {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, + ] return { - "resourceSpans": [{ + "resourceLogs": [{ "resource": { "attributes": [ {"key": "service.name", "value": {"stringValue": "dnastack-client"}}, {"key": "service.version", "value": {"stringValue": _DNASTACK_VERSION}}, ] }, - "scopeSpans": [{ + "scopeLogs": [{ "scope": {"name": "dnastack.publisher.questions"}, - "spans": [{ - "traceId": trace_id, - "spanId": span_id, - "name": "publisher.question.execute", - "startTimeUnixNano": str(start_time_ns), - "endTimeUnixNano": str(end_time_ns), - "status": {"code": status_code}, - "attributes": [ - {"key": "question.name", "value": {"stringValue": question_name}}, - {"key": "question.collection", "value": {"stringValue": collection}}, - {"key": "question.outcome", "value": {"stringValue": outcome}}, - {"key": "question.duration_ms", "value": {"doubleValue": (end_time_ns - start_time_ns) / 1_000_000}}, - *([{"key": "question.row_count", "value": {"doubleValue": float(row_count)}}] if row_count is not None else []), - {"key": "runtime.python", "value": {"stringValue": platform.python_version()}}, - ], + "logRecords": [{ + "timeUnixNano": str(end_time_ns), + "severityNumber": severity, + "body": {"stringValue": message}, + "attributes": attributes, }] }] }] @@ -69,7 +77,7 @@ def submit_telemetry( ) -> None: """Submit OTLP telemetry to collection-service. Errors are silently swallowed.""" try: - payload = build_otlp_span(question_name, collection, start_time_ns, end_time_ns, outcome, row_count) + payload = build_otlp_log(question_name, collection, start_time_ns, end_time_ns, outcome, row_count) client.submit_telemetry(payload) except Exception as e: logger.debug(f"Telemetry submission failed (non-fatal): {e}") diff --git a/dnastack/client/collections/client.py b/dnastack/client/collections/client.py index dc776276..84e1d572 100644 --- a/dnastack/client/collections/client.py +++ b/dnastack/client/collections/client.py @@ -401,10 +401,10 @@ def ask_question( ) def submit_telemetry(self, otlp_payload: dict, trace: Optional[Span] = None) -> None: - """Submit an OTLP trace payload. Best-effort — callers should handle failures gracefully.""" + """Submit an OTLP logs payload. Best-effort — callers should handle failures gracefully.""" with self.create_http_session() as session: session.post( - urljoin(self.url, 'otlp/v1/traces'), + urljoin(self.url, 'otlp/v1/logs'), json=otlp_payload, trace_context=trace ) diff --git a/tests/unit/client/test_collection_service_client.py b/tests/unit/client/test_collection_service_client.py index 814a3258..76298880 100644 --- a/tests/unit/client/test_collection_service_client.py +++ b/tests/unit/client/test_collection_service_client.py @@ -13,18 +13,18 @@ def _make_client(url='http://localhost:8093/'): class TestSubmitTelemetry: - def test_posts_to_otlp_traces_endpoint(self): + def test_posts_to_otlp_logs_endpoint(self): client = _make_client() mock_session = MagicMock() mock_session.__enter__ = MagicMock(return_value=mock_session) mock_session.__exit__ = MagicMock(return_value=False) with patch.object(client, 'create_http_session', return_value=mock_session): - client.submit_telemetry({'resourceSpans': []}) + client.submit_telemetry({'resourceLogs': []}) mock_session.post.assert_called_once_with( - 'http://localhost:8093/otlp/v1/traces', - json={'resourceSpans': []}, + 'http://localhost:8093/otlp/v1/logs', + json={'resourceLogs': []}, trace_context=None ) @@ -36,10 +36,10 @@ def test_passes_trace_context_when_provided(self): mock_trace = MagicMock() with patch.object(client, 'create_http_session', return_value=mock_session): - client.submit_telemetry({'resourceSpans': []}, trace=mock_trace) + client.submit_telemetry({'resourceLogs': []}, trace=mock_trace) mock_session.post.assert_called_once_with( - 'http://localhost:8093/otlp/v1/traces', - json={'resourceSpans': []}, + 'http://localhost:8093/otlp/v1/logs', + json={'resourceLogs': []}, trace_context=mock_trace ) diff --git a/tests/unit/publisher/test_telemetry.py b/tests/unit/publisher/test_telemetry.py index 14be6672..fcc45c79 100644 --- a/tests/unit/publisher/test_telemetry.py +++ b/tests/unit/publisher/test_telemetry.py @@ -1,39 +1,35 @@ import time from unittest.mock import MagicMock -from dnastack.cli.commands.publisher.questions.telemetry import build_otlp_span, submit_telemetry +from dnastack.cli.commands.publisher.questions.telemetry import build_otlp_log, submit_telemetry -class TestBuildOtlpSpan: +class TestBuildOtlpLog: - def test_returns_valid_otlp_structure(self): + def test_returns_valid_otlp_logs_structure(self): start_ns = time.time_ns() end_ns = start_ns + 1_000_000_000 - span = build_otlp_span('my-question', 'my-collection', start_ns, end_ns, 'success') - - assert 'resourceSpans' in span - resource_spans = span['resourceSpans'] - assert len(resource_spans) == 1 - scope_spans = resource_spans[0]['scopeSpans'] - assert len(scope_spans) == 1 - spans = scope_spans[0]['spans'] - assert len(spans) == 1 - s = spans[0] - assert s['name'] == 'publisher.question.execute' - assert s['startTimeUnixNano'] == str(start_ns) - assert s['endTimeUnixNano'] == str(end_ns) - - def test_success_outcome_sets_status_code_1(self): - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) - assert s['status']['code'] == 1 - - def test_error_outcome_sets_status_code_2(self): - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'error')) - assert s['status']['code'] == 2 + payload = build_otlp_log('my-question', 'my-collection', start_ns, end_ns, 'success') + + assert 'resourceLogs' in payload + resource_logs = payload['resourceLogs'] + assert len(resource_logs) == 1 + scope_logs = resource_logs[0]['scopeLogs'] + assert len(scope_logs) == 1 + log_records = scope_logs[0]['logRecords'] + assert len(log_records) == 1 + + def test_success_outcome_sets_severity_info(self): + lr = _first_log_record(build_otlp_log('q', 'c', 0, 1, 'success')) + assert lr['severityNumber'] == 9 + + def test_error_outcome_sets_severity_error(self): + lr = _first_log_record(build_otlp_log('q', 'c', 0, 1, 'error')) + assert lr['severityNumber'] == 17 def test_attributes_include_question_name_and_collection(self): - s = _first_span(build_otlp_span('my-q', 'my-col', 0, 1, 'success')) - attrs = {a['key']: a['value']['stringValue'] for a in s['attributes'] if 'stringValue' in a['value']} + lr = _first_log_record(build_otlp_log('my-q', 'my-col', 0, 1, 'success')) + attrs = _string_attrs(lr) assert attrs['question.name'] == 'my-q' assert attrs['question.collection'] == 'my-col' assert attrs['question.outcome'] == 'success' @@ -41,44 +37,44 @@ def test_attributes_include_question_name_and_collection(self): def test_duration_ms_is_calculated_from_start_and_end(self): start_ns = 1_000_000_000 end_ns = 2_500_000_000 # 1500 ms later - s = _first_span(build_otlp_span('q', 'c', start_ns, end_ns, 'success')) - double_attrs = {a['key']: a['value']['doubleValue'] for a in s['attributes'] if 'doubleValue' in a['value']} + lr = _first_log_record(build_otlp_log('q', 'c', start_ns, end_ns, 'success')) + double_attrs = _double_attrs(lr) assert double_attrs['question.duration_ms'] == 1500.0 def test_row_count_included_when_provided(self): - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success', row_count=42)) - double_attrs = {a['key']: a['value']['doubleValue'] for a in s['attributes'] if 'doubleValue' in a['value']} - assert double_attrs['question.row_count'] == 42.0 + lr = _first_log_record(build_otlp_log('q', 'c', 0, 1, 'success', row_count=42)) + assert _double_attrs(lr)['question.row_count'] == 42.0 def test_row_count_omitted_when_not_provided(self): - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'error')) - keys = [a['key'] for a in s['attributes']] - assert 'question.row_count' not in keys + lr = _first_log_record(build_otlp_log('q', 'c', 0, 1, 'error')) + assert 'question.row_count' not in [a['key'] for a in lr['attributes']] def test_resource_attributes_include_service_name(self): - span = build_otlp_span('q', 'c', 0, 1, 'success') + payload = build_otlp_log('q', 'c', 0, 1, 'success') resource_attrs = {a['key']: a['value']['stringValue'] - for a in span['resourceSpans'][0]['resource']['attributes']} + for a in payload['resourceLogs'][0]['resource']['attributes']} assert resource_attrs['service.name'] == 'dnastack-client' - def test_trace_and_span_ids_are_hex_strings(self): - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) - assert len(s['traceId']) == 32 - assert len(s['spanId']) == 16 - int(s['traceId'], 16) # must be valid hex - int(s['spanId'], 16) + def test_time_unix_nano_is_end_time(self): + end_ns = 1_713_369_601_000_000_000 + lr = _first_log_record(build_otlp_log('q', 'c', 0, end_ns, 'success')) + assert lr['timeUnixNano'] == str(end_ns) + + def test_success_message_includes_question_name_and_duration(self): + start_ns = 0 + end_ns = 1_500_000_000 # 1500 ms + lr = _first_log_record(build_otlp_log('my-question', 'c', start_ns, end_ns, 'success')) + assert 'my-question' in lr['body']['stringValue'] + assert '1500.0ms' in lr['body']['stringValue'] - def test_trace_id_overridden_by_env_var(self, monkeypatch): - fixed_trace_id = 'aabbccdd' * 4 - monkeypatch.setenv('DNASTACK_TRACE_ID', fixed_trace_id) - s = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) - assert s['traceId'] == fixed_trace_id + def test_success_message_includes_row_count_when_provided(self): + lr = _first_log_record(build_otlp_log('q', 'c', 0, 1_000_000_000, 'success', row_count=7)) + assert '7 rows' in lr['body']['stringValue'] - def test_trace_id_generated_randomly_when_env_var_not_set(self, monkeypatch): - monkeypatch.delenv('DNASTACK_TRACE_ID', raising=False) - s1 = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) - s2 = _first_span(build_otlp_span('q', 'c', 0, 1, 'success')) - assert s1['traceId'] != s2['traceId'] + def test_error_message_includes_question_name_and_duration(self): + lr = _first_log_record(build_otlp_log('bad-question', 'c', 0, 500_000_000, 'error')) + assert 'bad-question' in lr['body']['stringValue'] + assert '500.0ms' in lr['body']['stringValue'] class TestSubmitTelemetry: @@ -94,5 +90,13 @@ def test_swallows_client_errors_silently(self): submit_telemetry(client, 'q', 'c', 0, 1, 'success') # must not raise -def _first_span(otlp_payload: dict) -> dict: - return otlp_payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0] +def _first_log_record(otlp_payload: dict) -> dict: + return otlp_payload['resourceLogs'][0]['scopeLogs'][0]['logRecords'][0] + + +def _string_attrs(log_record: dict) -> dict: + return {a['key']: a['value']['stringValue'] for a in log_record['attributes'] if 'stringValue' in a['value']} + + +def _double_attrs(log_record: dict) -> dict: + return {a['key']: a['value']['doubleValue'] for a in log_record['attributes'] if 'doubleValue' in a['value']}