answerrocket-client 0.2.105__tar.gz → 0.2.106__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/PKG-INFO +1 -1
  2. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/__init__.py +1 -1
  3. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/client.py +2 -0
  4. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/data.py +6 -72
  5. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/graphql/client.py +1 -0
  6. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/graphql/schema.py +100 -1
  7. answerrocket_client-0.2.106/answer_rocket/observability.py +241 -0
  8. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answerrocket_client.egg-info/PKG-INFO +1 -1
  9. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answerrocket_client.egg-info/SOURCES.txt +3 -1
  10. answerrocket_client-0.2.106/test/test_observability.py +437 -0
  11. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/auth.py +0 -0
  12. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/chat.py +0 -0
  13. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/client_config.py +0 -0
  14. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/config.py +0 -0
  15. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/email.py +0 -0
  16. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/error.py +0 -0
  17. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/graphql/__init__.py +0 -0
  18. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/graphql/sdk_operations.py +0 -0
  19. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/layouts.py +0 -0
  20. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/llm.py +0 -0
  21. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/output.py +0 -0
  22. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/skill.py +0 -0
  23. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/types.py +0 -0
  24. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/util/__init__.py +0 -0
  25. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answer_rocket/util/meta_data_frame.py +0 -0
  26. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answerrocket_client.egg-info/dependency_links.txt +0 -0
  27. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answerrocket_client.egg-info/requires.txt +0 -0
  28. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/answerrocket_client.egg-info/top_level.txt +0 -0
  29. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/pyproject.toml +0 -0
  30. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/readme.md +0 -0
  31. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/setup.cfg +0 -0
  32. {answerrocket_client-0.2.105 → answerrocket_client-0.2.106}/test/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: answerrocket-client
3
- Version: 0.2.105
3
+ Version: 0.2.106
4
4
  Summary: Python client for interacting with AnswerRocket's skill API
5
5
  Requires-Python: >=3.10.7
6
6
  Description-Content-Type: text/markdown
@@ -5,7 +5,7 @@ __all__ = [
5
5
  'MetaDataFrame'
6
6
  ]
7
7
 
8
- __version__ = "0.2.105"
8
+ __version__ = "0.2.106"
9
9
 
10
10
  from answer_rocket.client import AnswerRocketClient
11
11
  from answer_rocket.error import AnswerRocketClientError
@@ -10,6 +10,7 @@ from answer_rocket.skill import Skill
10
10
  from answer_rocket.llm import Llm
11
11
  from answer_rocket.layouts import DynamicLayouts
12
12
  from answer_rocket.email import Email
13
+ from answer_rocket.observability import Observability
13
14
 
14
15
  class AnswerRocketClient:
15
16
  """
@@ -41,6 +42,7 @@ class AnswerRocketClient:
41
42
  self.llm = Llm(self._client_config, self._gql_client)
42
43
  self.dynamic_layouts = DynamicLayouts(self._client_config, self._gql_client)
43
44
  self.email = Email(self._client_config, self._gql_client)
45
+ self.observability = Observability(self._client_config, self._gql_client)
44
46
 
45
47
  def can_connect(self) -> bool:
46
48
  """
@@ -2452,33 +2452,9 @@ class Data:
2452
2452
  self, dataset_id: UUID
2453
2453
  ) -> List[TrackedDimensionAttribute]:
2454
2454
  """
2455
- Get currently-active starred values for the calling user on a dataset,
2456
- grouped by dimension.
2457
-
2458
- Parameters
2459
- ----------
2460
- dataset_id : UUID
2461
- Dataset the starred values belong to.
2462
-
2463
- Returns
2464
- -------
2465
- List[TrackedDimensionAttribute]
2466
- One entry per dimension the caller has stars in. Each entry exposes
2467
- ``dimension_attribute_id``, ``dimension_name``, and ``values``
2468
- (currently-active starred values for that dimension).
2469
-
2470
- Examples
2471
- --------
2472
- >>> attrs = max.tracked_values.get_tracked_dimension_values(
2473
- ... dataset_id=uuid.UUID("...")
2474
- ... )
2475
- >>> for attr in attrs:
2476
- ... print(attr.dimension_name, attr.values)
2477
- """
2478
- query_args = {"datasetId": str(dataset_id)}
2479
- op = Operations.query.get_tracked_dimension_values
2480
- result = self._gql_client.submit(op, query_args)
2481
- return result.get_tracked_dimension_values
2455
+ DEPRECATED in v0.2.106
2456
+ """
2457
+ raise DeprecationWarning("Deprecated in v0.2.106")
2482
2458
 
2483
2459
  def get_all_tracked_dimension_values(
2484
2460
  self,
@@ -2489,48 +2465,6 @@ class Data:
2489
2465
  sort: Optional[list] = None,
2490
2466
  ) -> TrackedDimensionValuesPage:
2491
2467
  """
2492
- Get a flat, paginated view of tracked values. Admins see every user's
2493
- rows; non-admins see only their own.
2494
-
2495
- Parameters
2496
- ----------
2497
- offset : int, default 0
2498
- Zero-based offset for paging.
2499
- limit : int, default 100
2500
- Max rows to return.
2501
- dataset_id : UUID, optional
2502
- If provided, scope results to a single dataset.
2503
- filters : dict, optional
2504
- AG-Grid filterModel JSON (per-column filters). Keys are column ids
2505
- (e.g. ``"userName"``, ``"value"``, ``"addedUtc"``); values follow
2506
- AG-Grid's text/set/date filter shapes.
2507
- sort : list, optional
2508
- AG-Grid sortModel JSON (list of ``{"colId": ..., "sort": "asc" |
2509
- "desc", "sortIndex": ...}`` entries).
2510
-
2511
- Returns
2512
- -------
2513
- TrackedDimensionValuesPage
2514
- Object with ``total_count`` (count after filters but before paging)
2515
- and ``rows`` (the requested page of joined user/dataset rows).
2516
-
2517
- Examples
2518
- --------
2519
- >>> page = max.tracked_values.get_all_tracked_dimension_values(
2520
- ... dataset_id=uuid.UUID("..."),
2521
- ... limit=500,
2522
- ... )
2523
- >>> print(page.total_count)
2524
- >>> for row in page.rows:
2525
- ... print(row.user_name, row.dimension_name, row.value)
2526
- """
2527
- query_args: dict[str, Any] = {
2528
- "offset": offset,
2529
- "limit": limit,
2530
- "datasetId": str(dataset_id) if dataset_id is not None else None,
2531
- "filters": filters,
2532
- "sort": sort,
2533
- }
2534
- op = Operations.query.get_all_tracked_dimension_values
2535
- result = self._gql_client.submit(op, query_args)
2536
- return result.get_all_tracked_dimension_values
2468
+ DEPRECATED in v0.2.106
2469
+ """
2470
+ raise DeprecationWarning("Deprecated in v0.2.106")
@@ -34,3 +34,4 @@ class GraphQlClient:
34
34
  if variables:
35
35
  return Operation(Mutation, variables=variables)
36
36
  return Operation(Mutation)
37
+
@@ -335,6 +335,22 @@ class MaxDomainEntity(sgqlc.types.Interface):
335
335
  attributes = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(MaxDomainAttribute))), graphql_name='attributes')
336
336
 
337
337
 
338
+ class AnyValue(sgqlc.types.Type):
339
+ __schema__ = schema
340
+ __field_names__ = ('string_value', 'bool_value', 'int_value', 'double_value', 'array_value')
341
+ string_value = sgqlc.types.Field(String, graphql_name='stringValue')
342
+ bool_value = sgqlc.types.Field(Boolean, graphql_name='boolValue')
343
+ int_value = sgqlc.types.Field(String, graphql_name='intValue')
344
+ double_value = sgqlc.types.Field(Float, graphql_name='doubleValue')
345
+ array_value = sgqlc.types.Field('ArrayValue', graphql_name='arrayValue')
346
+
347
+
348
+ class ArrayValue(sgqlc.types.Type):
349
+ __schema__ = schema
350
+ __field_names__ = ('values',)
351
+ values = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(AnyValue))), graphql_name='values')
352
+
353
+
338
354
  class AsyncSkillRunResponse(sgqlc.types.Type):
339
355
  __schema__ = schema
340
356
  __field_names__ = ('execution_id', 'success', 'code', 'error')
@@ -763,6 +779,20 @@ class HydratedReport(sgqlc.types.Type):
763
779
  meta = sgqlc.types.Field(JSON, graphql_name='meta')
764
780
 
765
781
 
782
+ class InstrumentationScope(sgqlc.types.Type):
783
+ __schema__ = schema
784
+ __field_names__ = ('name', 'version')
785
+ name = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='name')
786
+ version = sgqlc.types.Field(String, graphql_name='version')
787
+
788
+
789
+ class KeyValue(sgqlc.types.Type):
790
+ __schema__ = schema
791
+ __field_names__ = ('key', 'value')
792
+ key = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='key')
793
+ value = sgqlc.types.Field(sgqlc.types.non_null(AnyValue), graphql_name='value')
794
+
795
+
766
796
  class LayoutPdfResponse(sgqlc.types.Type):
767
797
  __schema__ = schema
768
798
  __field_names__ = ('success', 'code', 'error', 'pdf')
@@ -1636,6 +1666,27 @@ class Mutation(sgqlc.types.Type):
1636
1666
  )
1637
1667
 
1638
1668
 
1669
+ class ObservabilityTrace(sgqlc.types.Type):
1670
+ __schema__ = schema
1671
+ __field_names__ = ('resource_spans',)
1672
+ resource_spans = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null('ResourceSpans'))), graphql_name='resourceSpans')
1673
+
1674
+
1675
+ class ObservabilityTracesResult(sgqlc.types.Type):
1676
+ __schema__ = schema
1677
+ __field_names__ = ('count', 'has_more', 'next_cursor', 'traces')
1678
+ count = sgqlc.types.Field(sgqlc.types.non_null(Int), graphql_name='count')
1679
+ has_more = sgqlc.types.Field(sgqlc.types.non_null(Boolean), graphql_name='hasMore')
1680
+ next_cursor = sgqlc.types.Field(String, graphql_name='nextCursor')
1681
+ traces = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(ObservabilityTrace))), graphql_name='traces')
1682
+
1683
+
1684
+ class OtlpResource(sgqlc.types.Type):
1685
+ __schema__ = schema
1686
+ __field_names__ = ('attributes',)
1687
+ attributes = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(KeyValue))), graphql_name='attributes')
1688
+
1689
+
1639
1690
  class PagedChatArtifacts(sgqlc.types.Type):
1640
1691
  __schema__ = schema
1641
1692
  __field_names__ = ('total_rows', 'rows')
@@ -1696,7 +1747,7 @@ class ParameterDefinition(sgqlc.types.Type):
1696
1747
 
1697
1748
  class Query(sgqlc.types.Type):
1698
1749
  __schema__ = schema
1699
- __field_names__ = ('ping', 'current_user', 'get_copilot_skill_artifact_by_path', 'get_copilots', 'get_copilot_info', 'get_copilot_skill', 'run_copilot_skill', 'get_skill_components', 'get_copilot_hydrated_reports', 'get_async_skill_run_status', 'get_copilot_test_run', 'get_paged_copilot_test_runs', 'get_copilot_question_folders', 'get_max_agent_workflow', 'execute_sql_query', 'execute_rql_query', 'get_databases', 'get_database', 'get_database_tables', 'get_dataset_id', 'get_dataset', 'get_dataset2', 'get_datasets', 'get_domain_object', 'get_domain_object_by_name', 'get_grounded_value', 'get_database_kshots', 'get_database_kshot_by_id', 'get_dataset_kshots', 'get_dataset_kshot_by_id', 'run_max_sql_gen', 'run_sql_ai', 'generate_visualization', 'llmapi_config_for_sdk', 'generate_embeddings', 'get_max_llm_prompt', 'user_chat_threads', 'user_chat_entries', 'chat_thread', 'chat_entry', 'user', 'all_chat_entries', 'skill_memory', 'chat_completion', 'narrative_completion', 'narrative_completion_with_prompt', 'sql_completion', 'research_completion', 'chat_completion_with_prompt', 'research_completion_with_prompt', 'get_chat_artifact', 'get_chat_artifacts', 'get_dynamic_layout', 'get_tracked_dimension_values', 'get_all_tracked_dimension_values')
1750
+ __field_names__ = ('ping', 'current_user', 'get_copilot_skill_artifact_by_path', 'get_copilots', 'get_copilot_info', 'get_copilot_skill', 'run_copilot_skill', 'get_skill_components', 'get_copilot_hydrated_reports', 'get_async_skill_run_status', 'get_copilot_test_run', 'get_paged_copilot_test_runs', 'get_copilot_question_folders', 'get_max_agent_workflow', 'execute_sql_query', 'execute_rql_query', 'get_databases', 'get_database', 'get_database_tables', 'get_dataset_id', 'get_dataset', 'get_dataset2', 'get_datasets', 'get_domain_object', 'get_domain_object_by_name', 'get_grounded_value', 'get_database_kshots', 'get_database_kshot_by_id', 'get_dataset_kshots', 'get_dataset_kshot_by_id', 'run_max_sql_gen', 'run_sql_ai', 'generate_visualization', 'llmapi_config_for_sdk', 'generate_embeddings', 'get_max_llm_prompt', 'user_chat_threads', 'user_chat_entries', 'chat_thread', 'chat_entry', 'user', 'all_chat_entries', 'skill_memory', 'chat_completion', 'narrative_completion', 'narrative_completion_with_prompt', 'sql_completion', 'research_completion', 'chat_completion_with_prompt', 'research_completion_with_prompt', 'get_chat_artifact', 'get_chat_artifacts', 'get_dynamic_layout', 'get_tracked_dimension_values', 'get_all_tracked_dimension_values', 'observability_traces')
1700
1751
  ping = sgqlc.types.Field(String, graphql_name='ping')
1701
1752
  current_user = sgqlc.types.Field(MaxUser, graphql_name='currentUser')
1702
1753
  get_copilot_skill_artifact_by_path = sgqlc.types.Field(CopilotSkillArtifact, graphql_name='getCopilotSkillArtifactByPath', args=sgqlc.types.ArgDict((
@@ -1989,6 +2040,18 @@ class Query(sgqlc.types.Type):
1989
2040
  ('sort', sgqlc.types.Arg(JSON, graphql_name='sort', default=None)),
1990
2041
  ))
1991
2042
  )
2043
+ observability_traces = sgqlc.types.Field(sgqlc.types.non_null(ObservabilityTracesResult), graphql_name='observabilityTraces', args=sgqlc.types.ArgDict((
2044
+ ('since', sgqlc.types.Arg(sgqlc.types.non_null(String), graphql_name='since', default=None)),
2045
+ ('limit', sgqlc.types.Arg(Int, graphql_name='limit', default=None)),
2046
+ ))
2047
+ )
2048
+
2049
+
2050
+ class ResourceSpans(sgqlc.types.Type):
2051
+ __schema__ = schema
2052
+ __field_names__ = ('resource', 'scope_spans')
2053
+ resource = sgqlc.types.Field(sgqlc.types.non_null(OtlpResource), graphql_name='resource')
2054
+ scope_spans = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null('ScopeSpans'))), graphql_name='scopeSpans')
1992
2055
 
1993
2056
 
1994
2057
  class RunMaxSqlGenResponse(sgqlc.types.Type):
@@ -2019,6 +2082,13 @@ class RunSqlAiResponse(sgqlc.types.Type):
2019
2082
  prior_runs = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null('RunSqlAiResponse'))), graphql_name='priorRuns')
2020
2083
 
2021
2084
 
2085
+ class ScopeSpans(sgqlc.types.Type):
2086
+ __schema__ = schema
2087
+ __field_names__ = ('scope', 'spans')
2088
+ scope = sgqlc.types.Field(sgqlc.types.non_null(InstrumentationScope), graphql_name='scope')
2089
+ spans = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null('Span'))), graphql_name='spans')
2090
+
2091
+
2022
2092
  class SharedThread(sgqlc.types.Type):
2023
2093
  __schema__ = schema
2024
2094
  __field_names__ = ('id', 'user_id', 'original_thread_id', 'copilot_id', 'shared_by', 'last_updated_utc', 'created_utc', 'is_deleted', 'link_to_shared_thread')
@@ -2066,6 +2136,35 @@ class SkillParameter(sgqlc.types.Type):
2066
2136
  match_values = sgqlc.types.Field(MatchValues, graphql_name='matchValues')
2067
2137
 
2068
2138
 
2139
+ class Span(sgqlc.types.Type):
2140
+ __schema__ = schema
2141
+ __field_names__ = ('trace_id', 'span_id', 'parent_span_id', 'name', 'kind', 'start_time_unix_nano', 'end_time_unix_nano', 'attributes', 'events', 'status')
2142
+ trace_id = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='traceId')
2143
+ span_id = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='spanId')
2144
+ parent_span_id = sgqlc.types.Field(String, graphql_name='parentSpanId')
2145
+ name = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='name')
2146
+ kind = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='kind')
2147
+ start_time_unix_nano = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='startTimeUnixNano')
2148
+ end_time_unix_nano = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='endTimeUnixNano')
2149
+ attributes = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(KeyValue))), graphql_name='attributes')
2150
+ events = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null('SpanEvent'))), graphql_name='events')
2151
+ status = sgqlc.types.Field('SpanStatus', graphql_name='status')
2152
+
2153
+
2154
+ class SpanEvent(sgqlc.types.Type):
2155
+ __schema__ = schema
2156
+ __field_names__ = ('time_unix_nano', 'name', 'attributes')
2157
+ time_unix_nano = sgqlc.types.Field(String, graphql_name='timeUnixNano')
2158
+ name = sgqlc.types.Field(sgqlc.types.non_null(String), graphql_name='name')
2159
+ attributes = sgqlc.types.Field(sgqlc.types.non_null(sgqlc.types.list_of(sgqlc.types.non_null(KeyValue))), graphql_name='attributes')
2160
+
2161
+
2162
+ class SpanStatus(sgqlc.types.Type):
2163
+ __schema__ = schema
2164
+ __field_names__ = ('code',)
2165
+ code = sgqlc.types.Field(String, graphql_name='code')
2166
+
2167
+
2069
2168
  class TrackedDimensionAttribute(sgqlc.types.Type):
2070
2169
  __schema__ = schema
2071
2170
  __field_names__ = ('dimension_attribute_id', 'dimension_name', 'values')
@@ -0,0 +1,241 @@
1
+ """
2
+ Observability client for AnswerRocket.
3
+
4
+ Fetches pre-assembled OTLP/JSON traces from the observabilityTraces GraphQL
5
+ endpoint and delivers them to callers for forwarding to any OTel collector's
6
+ /v1/traces endpoint.
7
+
8
+ OTLP assembly is performed server-side in Kotlin (ObservabilityPublicSchemaModule).
9
+ Each trace is a complete OTLP ExportTraceServiceRequest object.
10
+
11
+ Usage patterns:
12
+
13
+ # Single page
14
+ batch = client.observability.get_traces(since="2026-05-01T00:00:00Z")
15
+
16
+ # All pages from a cursor
17
+ for batch in client.observability.iter_traces(since="2026-05-01T00:00:00Z"):
18
+ forward_to_collector(batch)
19
+
20
+ # Continuous polling loop
21
+ for batch in client.observability.poll_traces(since="2026-05-01T00:00:00Z"):
22
+ forward_to_collector(batch)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
+ from typing import Callable, Generator, Iterator
32
+
33
+ from answer_rocket.client_config import ClientConfig
34
+ from answer_rocket.graphql.client import GraphQlClient
35
+
36
+ _logger = logging.getLogger("answer_rocket.observability")
37
+
38
+ DEFAULT_LIMIT = 100
39
+ DEFAULT_POLL_INTERVAL_SECONDS = 60.0
40
+
41
+
42
+ @dataclass
43
+ class TracesBatch:
44
+ """One page of OTLP/JSON traces."""
45
+ success: bool = False
46
+ error: str | None = None
47
+ count: int = 0
48
+ has_more: bool = False
49
+ next_cursor: str | None = None
50
+ traces: list[dict] = field(default_factory=list)
51
+
52
+
53
+ class Observability:
54
+ def __init__(self, config: ClientConfig, gql_client: GraphQlClient):
55
+ self._config = config
56
+ self._gql_client = gql_client
57
+
58
+ def get_traces(
59
+ self,
60
+ since: str | datetime,
61
+ limit: int = DEFAULT_LIMIT,
62
+ ) -> TracesBatch:
63
+ since_str = _to_iso(since)
64
+ try:
65
+ op = self._gql_client.query()
66
+ obs = op.observability_traces(since=since_str, limit=limit)
67
+ obs.count()
68
+ obs.has_more()
69
+ obs.next_cursor()
70
+ # The full OTLP span tree is strongly typed in the schema, so every field
71
+ # (including attribute KeyValue/AnyValue) must be selected explicitly.
72
+ resource_spans = obs.traces().resource_spans()
73
+ _select_key_values(resource_spans.resource().attributes())
74
+ scope_spans = resource_spans.scope_spans()
75
+ scope = scope_spans.scope()
76
+ scope.name()
77
+ scope.version()
78
+ spans = scope_spans.spans()
79
+ spans.trace_id()
80
+ spans.span_id()
81
+ spans.parent_span_id()
82
+ spans.name()
83
+ spans.kind()
84
+ spans.start_time_unix_nano()
85
+ spans.end_time_unix_nano()
86
+ _select_key_values(spans.attributes())
87
+ events = spans.events()
88
+ events.time_unix_nano()
89
+ events.name()
90
+ _select_key_values(events.attributes())
91
+ spans.status().code()
92
+
93
+ result = self._gql_client.submit(op)
94
+ page = result.observability_traces
95
+ except Exception as exc:
96
+ return TracesBatch(success=False, error=str(exc))
97
+
98
+ return TracesBatch(
99
+ success=True,
100
+ count=page.count,
101
+ has_more=page.has_more,
102
+ next_cursor=page.next_cursor,
103
+ traces=[_trace_to_otlp(t) for t in (page.traces or [])],
104
+ )
105
+
106
+ def iter_traces(
107
+ self,
108
+ since: str | datetime,
109
+ limit: int = DEFAULT_LIMIT,
110
+ ) -> Iterator[list[dict]]:
111
+ cursor: str | datetime = since
112
+ while True:
113
+ result = self.get_traces(cursor, limit=limit)
114
+ if not result.success:
115
+ _logger.error("iter_traces page error: %s", result.error)
116
+ return
117
+ if result.traces:
118
+ yield result.traces
119
+ if not result.has_more or not result.next_cursor:
120
+ return
121
+ cursor = result.next_cursor
122
+
123
+ def poll_traces(
124
+ self,
125
+ since: str | datetime,
126
+ on_batch: Callable[[list[dict]], None] | None = None,
127
+ limit: int = DEFAULT_LIMIT,
128
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
129
+ max_iterations: int | None = None,
130
+ ) -> Generator[list[dict], None, None]:
131
+ cursor: str | datetime = since
132
+ iterations = 0
133
+ while max_iterations is None or iterations < max_iterations:
134
+ result = self.get_traces(cursor, limit=limit)
135
+ if not result.success:
136
+ _logger.warning("poll_traces error (will retry): %s", result.error)
137
+ time.sleep(poll_interval_seconds)
138
+ iterations += 1
139
+ continue
140
+ if result.traces:
141
+ if on_batch is not None:
142
+ on_batch(result.traces)
143
+ yield result.traces
144
+ if result.next_cursor:
145
+ cursor = result.next_cursor
146
+ else:
147
+ time.sleep(poll_interval_seconds)
148
+ iterations += 1
149
+
150
+
151
+ def _to_iso(dt: str | datetime) -> str:
152
+ if isinstance(dt, datetime):
153
+ if dt.tzinfo is None:
154
+ dt = dt.replace(tzinfo=timezone.utc)
155
+ return dt.isoformat()
156
+ return dt
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # GraphQL selection + reconstruction of OTLP/JSON from the typed span tree.
161
+ #
162
+ # The server emits a fully-typed, versionable span tree; collectors want plain OTLP
163
+ # JSON. These helpers select every typed field and map the objects back to the OTLP
164
+ # wire shape, omitting absent optional fields so the output matches an OTLP exporter.
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def _select_key_values(kv_selector) -> None:
168
+ """Select an OTLP KeyValue list, including one level of AnyValue.arrayValue."""
169
+ kv_selector.key()
170
+ value = kv_selector.value()
171
+ value.string_value()
172
+ value.bool_value()
173
+ value.int_value()
174
+ value.double_value()
175
+ inner = value.array_value().values()
176
+ inner.string_value()
177
+ inner.bool_value()
178
+ inner.int_value()
179
+ inner.double_value()
180
+
181
+
182
+ def _trace_to_otlp(trace) -> dict:
183
+ return {"resourceSpans": [_resource_spans_to_otlp(rs) for rs in (trace.resource_spans or [])]}
184
+
185
+
186
+ def _resource_spans_to_otlp(rs) -> dict:
187
+ return {
188
+ "resource": {"attributes": [_key_value_to_otlp(kv) for kv in (rs.resource.attributes or [])]},
189
+ "scopeSpans": [_scope_spans_to_otlp(ss) for ss in (rs.scope_spans or [])],
190
+ }
191
+
192
+
193
+ def _scope_spans_to_otlp(ss) -> dict:
194
+ scope: dict = {"name": ss.scope.name}
195
+ if ss.scope.version is not None:
196
+ scope["version"] = ss.scope.version
197
+ return {"scope": scope, "spans": [_span_to_otlp(s) for s in (ss.spans or [])]}
198
+
199
+
200
+ def _span_to_otlp(s) -> dict:
201
+ out = {
202
+ "traceId": s.trace_id,
203
+ "spanId": s.span_id,
204
+ "name": s.name,
205
+ "kind": s.kind,
206
+ "startTimeUnixNano": s.start_time_unix_nano,
207
+ "endTimeUnixNano": s.end_time_unix_nano,
208
+ "attributes": [_key_value_to_otlp(kv) for kv in (s.attributes or [])],
209
+ "events": [_event_to_otlp(e) for e in (s.events or [])],
210
+ }
211
+ if s.parent_span_id is not None:
212
+ out["parentSpanId"] = s.parent_span_id
213
+ if s.status is not None:
214
+ out["status"] = {"code": s.status.code}
215
+ return out
216
+
217
+
218
+ def _event_to_otlp(e) -> dict:
219
+ out = {"name": e.name, "attributes": [_key_value_to_otlp(kv) for kv in (e.attributes or [])]}
220
+ if e.time_unix_nano is not None:
221
+ out["timeUnixNano"] = e.time_unix_nano
222
+ return out
223
+
224
+
225
+ def _key_value_to_otlp(kv) -> dict:
226
+ return {"key": kv.key, "value": _any_value_to_otlp(kv.value)}
227
+
228
+
229
+ def _any_value_to_otlp(v) -> dict:
230
+ """Map a typed OTLP AnyValue back to its single-key wire form."""
231
+ if v.string_value is not None:
232
+ return {"stringValue": v.string_value}
233
+ if v.bool_value is not None:
234
+ return {"boolValue": v.bool_value}
235
+ if v.int_value is not None:
236
+ return {"intValue": v.int_value}
237
+ if v.double_value is not None:
238
+ return {"doubleValue": v.double_value}
239
+ if v.array_value is not None:
240
+ return {"arrayValue": {"values": [_any_value_to_otlp(x) for x in (v.array_value.values or [])]}}
241
+ return {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: answerrocket-client
3
- Version: 0.2.105
3
+ Version: 0.2.106
4
4
  Summary: Python client for interacting with AnswerRocket's skill API
5
5
  Requires-Python: >=3.10.7
6
6
  Description-Content-Type: text/markdown
@@ -11,6 +11,7 @@ answer_rocket/email.py
11
11
  answer_rocket/error.py
12
12
  answer_rocket/layouts.py
13
13
  answer_rocket/llm.py
14
+ answer_rocket/observability.py
14
15
  answer_rocket/output.py
15
16
  answer_rocket/skill.py
16
17
  answer_rocket/types.py
@@ -25,4 +26,5 @@ answerrocket_client.egg-info/SOURCES.txt
25
26
  answerrocket_client.egg-info/dependency_links.txt
26
27
  answerrocket_client.egg-info/requires.txt
27
28
  answerrocket_client.egg-info/top_level.txt
28
- test/test_client.py
29
+ test/test_client.py
30
+ test/test_observability.py
@@ -0,0 +1,437 @@
1
+ """Tests for the Observability client (GraphQL-backed, OTLP assembled server-side)."""
2
+
3
+ import sys
4
+ import os
5
+ import json
6
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from unittest.mock import MagicMock, patch
9
+ from datetime import datetime, timezone
10
+
11
+ from answer_rocket.observability import Observability, TracesBatch, DEFAULT_LIMIT
12
+ from answer_rocket.client_config import ClientConfig
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Helpers
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _SAMPLE_OTLP_TRACE = {
19
+ "resourceSpans": [{
20
+ "resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "answerrocket-copilot"}}]},
21
+ "scopeSpans": [{"scope": {"name": "answerrocket.copilot"}, "spans": [
22
+ {"traceId": "abc123", "spanId": "def456", "name": "chat.pipeline",
23
+ "kind": "SPAN_KIND_SERVER", "startTimeUnixNano": "1000", "endTimeUnixNano": "2000",
24
+ "attributes": [], "events": [], "status": {"code": "STATUS_CODE_OK"}},
25
+ ]}],
26
+ }]
27
+ }
28
+
29
+
30
+ def _typed_any_value(v):
31
+ """Mimic a typed sgqlc AnyValue from an OTLP value dict (one populated variant)."""
32
+ m = MagicMock()
33
+ m.string_value = v.get("stringValue")
34
+ m.bool_value = v.get("boolValue")
35
+ m.int_value = v.get("intValue")
36
+ m.double_value = v.get("doubleValue")
37
+ if "arrayValue" in v:
38
+ arr = MagicMock(); arr.values = [_typed_any_value(x) for x in v["arrayValue"]["values"]]
39
+ m.array_value = arr
40
+ else:
41
+ m.array_value = None
42
+ return m
43
+
44
+
45
+ def _typed_key_value(kv):
46
+ m = MagicMock(); m.key = kv["key"]; m.value = _typed_any_value(kv["value"])
47
+ return m
48
+
49
+
50
+ def _typed_event(e):
51
+ m = MagicMock(); m.name = e["name"]; m.time_unix_nano = e.get("timeUnixNano")
52
+ m.attributes = [_typed_key_value(kv) for kv in e.get("attributes", [])]
53
+ return m
54
+
55
+
56
+ def _typed_span(s):
57
+ """Mimic a typed sgqlc Span object built from an OTLP span dict."""
58
+ m = MagicMock()
59
+ m.trace_id = s["traceId"]
60
+ m.span_id = s["spanId"]
61
+ m.name = s["name"]
62
+ m.kind = s["kind"]
63
+ m.start_time_unix_nano = s["startTimeUnixNano"]
64
+ m.end_time_unix_nano = s["endTimeUnixNano"]
65
+ m.attributes = [_typed_key_value(kv) for kv in s.get("attributes", [])]
66
+ m.events = [_typed_event(e) for e in s.get("events", [])] # always a list (non-null in schema)
67
+ m.parent_span_id = s.get("parentSpanId") # None when absent
68
+ if "status" in s:
69
+ st = MagicMock(); st.code = s["status"]["code"]; m.status = st
70
+ else:
71
+ m.status = None
72
+ return m
73
+
74
+
75
+ def _typed_scope_spans(ss):
76
+ m = MagicMock()
77
+ sc = MagicMock(); sc.name = ss["scope"]["name"]; sc.version = ss["scope"].get("version")
78
+ m.scope = sc
79
+ m.spans = [_typed_span(s) for s in ss["spans"]]
80
+ return m
81
+
82
+
83
+ def _typed_resource_spans(rs):
84
+ m = MagicMock()
85
+ res = MagicMock(); res.attributes = [_typed_key_value(kv) for kv in rs["resource"]["attributes"]]
86
+ m.resource = res
87
+ m.scope_spans = [_typed_scope_spans(ss) for ss in rs["scopeSpans"]]
88
+ return m
89
+
90
+
91
+ def _typed_trace(otlp):
92
+ """Mimic the typed sgqlc ObservabilityTrace tree built from an OTLP dict."""
93
+ t = MagicMock()
94
+ t.resource_spans = [_typed_resource_spans(rs) for rs in otlp["resourceSpans"]]
95
+ return t
96
+
97
+
98
+ def _make_gql_page(traces=None, count=None, has_more=False, next_cursor=None):
99
+ """Build a mock sgqlc result whose `traces` are typed objects (as the server returns)."""
100
+ otlp = traces or []
101
+ page = MagicMock()
102
+ page.count = count if count is not None else len(otlp)
103
+ page.has_more = has_more
104
+ page.next_cursor = next_cursor
105
+ page.traces = [_typed_trace(t) for t in otlp]
106
+ return page
107
+
108
+
109
+ def _make_client():
110
+ config = MagicMock(spec=ClientConfig)
111
+ config.tenant = "test_tenant"
112
+ gql_client = MagicMock()
113
+ return config, gql_client, Observability(config, gql_client)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Basic attribute tests
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def test_client_has_observability_attr():
121
+ _, _, obs = _make_client()
122
+ assert isinstance(obs, Observability)
123
+
124
+
125
+ def test_to_iso_string_passthrough():
126
+ from answer_rocket.observability import _to_iso
127
+ assert _to_iso("2026-01-01T00:00:00Z") == "2026-01-01T00:00:00Z"
128
+
129
+
130
+ def test_to_iso_datetime_naive():
131
+ from answer_rocket.observability import _to_iso
132
+ dt = datetime(2026, 1, 1, 12, 0, 0)
133
+ result = _to_iso(dt)
134
+ assert "2026-01-01" in result
135
+ assert "+00:00" in result or "UTC" in result
136
+
137
+
138
+ def test_to_iso_datetime_aware():
139
+ from answer_rocket.observability import _to_iso
140
+ dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
141
+ result = _to_iso(dt)
142
+ assert "2026-01-01" in result
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # get_traces
147
+ # ---------------------------------------------------------------------------
148
+
149
+ def test_get_traces_success():
150
+ config, gql_client, obs = _make_client()
151
+ mock_result = MagicMock()
152
+ mock_result.observability_traces = _make_gql_page(
153
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=False
154
+ )
155
+ gql_client.submit.return_value = mock_result
156
+
157
+ batch = obs.get_traces("2026-01-01T00:00:00Z")
158
+
159
+ assert batch.success is True
160
+ assert batch.count == 1
161
+ assert batch.has_more is False
162
+ assert batch.next_cursor is None
163
+ assert len(batch.traces) == 1
164
+ assert batch.traces[0] == _SAMPLE_OTLP_TRACE
165
+
166
+
167
+ def test_get_traces_passes_correct_variables():
168
+ config, gql_client, obs = _make_client()
169
+ mock_result = MagicMock()
170
+ mock_result.observability_traces = _make_gql_page()
171
+ gql_client.submit.return_value = mock_result
172
+
173
+ mock_op = MagicMock()
174
+ mock_obs_field = MagicMock()
175
+ mock_op.observability_traces.return_value = mock_obs_field
176
+ gql_client.query.return_value = mock_op
177
+
178
+ obs.get_traces("2026-05-01T00:00:00Z", limit=50)
179
+
180
+ mock_op.observability_traces.assert_called_once_with(since="2026-05-01T00:00:00Z", limit=50)
181
+
182
+
183
+ def test_get_traces_pagination_fields():
184
+ config, gql_client, obs = _make_client()
185
+ mock_result = MagicMock()
186
+ mock_result.observability_traces = _make_gql_page(
187
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=True, next_cursor="2026-06-01T00:00:00Z"
188
+ )
189
+ gql_client.submit.return_value = mock_result
190
+
191
+ batch = obs.get_traces("2026-01-01T00:00:00Z")
192
+
193
+ assert batch.has_more is True
194
+ assert batch.next_cursor == "2026-06-01T00:00:00Z"
195
+
196
+
197
+ def test_get_traces_connection_error():
198
+ config, gql_client, obs = _make_client()
199
+ gql_client.submit.side_effect = ConnectionError("timeout")
200
+
201
+ batch = obs.get_traces("2026-01-01T00:00:00Z")
202
+
203
+ assert batch.success is False
204
+ assert "timeout" in batch.error
205
+ assert batch.traces == []
206
+
207
+
208
+ def test_get_traces_empty_page():
209
+ config, gql_client, obs = _make_client()
210
+ mock_result = MagicMock()
211
+ mock_result.observability_traces = _make_gql_page(traces=[], count=0, has_more=False)
212
+ gql_client.submit.return_value = mock_result
213
+
214
+ batch = obs.get_traces("2026-01-01T00:00:00Z")
215
+
216
+ assert batch.success is True
217
+ assert batch.count == 0
218
+ assert batch.traces == []
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # iter_traces
223
+ # ---------------------------------------------------------------------------
224
+
225
+ def test_iter_traces_follows_pages():
226
+ config, gql_client, obs = _make_client()
227
+ page1 = MagicMock()
228
+ page1.observability_traces = _make_gql_page(
229
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=True, next_cursor="cursor_1"
230
+ )
231
+ page2 = MagicMock()
232
+ page2.observability_traces = _make_gql_page(
233
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=False
234
+ )
235
+ gql_client.submit.side_effect = [page1, page2]
236
+
237
+ batches = list(obs.iter_traces("2026-01-01T00:00:00Z"))
238
+
239
+ assert len(batches) == 2
240
+ assert gql_client.submit.call_count == 2
241
+
242
+
243
+ def test_iter_traces_stops_on_empty():
244
+ config, gql_client, obs = _make_client()
245
+ mock_result = MagicMock()
246
+ mock_result.observability_traces = _make_gql_page(traces=[], has_more=False)
247
+ gql_client.submit.return_value = mock_result
248
+
249
+ batches = list(obs.iter_traces("2026-01-01T00:00:00Z"))
250
+
251
+ assert batches == []
252
+
253
+
254
+ def test_iter_traces_stops_on_error():
255
+ config, gql_client, obs = _make_client()
256
+ gql_client.submit.side_effect = Exception("server error")
257
+
258
+ batches = list(obs.iter_traces("2026-01-01T00:00:00Z"))
259
+
260
+ assert batches == []
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # poll_traces
265
+ # ---------------------------------------------------------------------------
266
+
267
+ def test_poll_traces_max_iterations():
268
+ config, gql_client, obs = _make_client()
269
+ mock_result = MagicMock()
270
+ mock_result.observability_traces = _make_gql_page(
271
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=False
272
+ )
273
+ gql_client.submit.return_value = mock_result
274
+
275
+ with patch("time.sleep"):
276
+ batches = list(obs.poll_traces("2026-01-01T00:00:00Z", max_iterations=3))
277
+
278
+ assert len(batches) == 3
279
+
280
+
281
+ def test_poll_traces_skips_empty_batches():
282
+ config, gql_client, obs = _make_client()
283
+ mock_result = MagicMock()
284
+ mock_result.observability_traces = _make_gql_page(traces=[], has_more=False)
285
+ gql_client.submit.return_value = mock_result
286
+
287
+ with patch("time.sleep"):
288
+ batches = list(obs.poll_traces("2026-01-01T00:00:00Z", max_iterations=2))
289
+
290
+ assert batches == []
291
+
292
+
293
+ def test_poll_traces_on_batch_callback():
294
+ config, gql_client, obs = _make_client()
295
+ mock_result = MagicMock()
296
+ mock_result.observability_traces = _make_gql_page(
297
+ traces=[_SAMPLE_OTLP_TRACE], count=1, has_more=False
298
+ )
299
+ gql_client.submit.return_value = mock_result
300
+
301
+ received = []
302
+ with patch("time.sleep"):
303
+ for _ in obs.poll_traces("2026-01-01T00:00:00Z", on_batch=received.extend, max_iterations=1):
304
+ pass
305
+
306
+ assert len(received) == 1
307
+ assert received[0] == _SAMPLE_OTLP_TRACE
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Cursor handling
312
+ # ---------------------------------------------------------------------------
313
+
314
+ def test_get_traces_converts_datetime_cursor_to_iso():
315
+ config, gql_client, obs = _make_client()
316
+ mock_result = MagicMock()
317
+ mock_result.observability_traces = _make_gql_page()
318
+ gql_client.submit.return_value = mock_result
319
+ mock_op = MagicMock()
320
+ gql_client.query.return_value = mock_op
321
+
322
+ obs.get_traces(datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc), limit=10)
323
+
324
+ _, kwargs = mock_op.observability_traces.call_args
325
+ assert kwargs["since"].startswith("2026-05-01T12:00:00")
326
+ assert kwargs["limit"] == 10
327
+
328
+
329
+ def test_iter_traces_threads_next_cursor_as_since():
330
+ """Page 2 must be requested with `since` == page 1's next_cursor (no re-scan, no gaps)."""
331
+ config, gql_client, obs = _make_client()
332
+ page1 = MagicMock()
333
+ page1.observability_traces = _make_gql_page(
334
+ traces=[_SAMPLE_OTLP_TRACE], has_more=True, next_cursor="2026-05-01T12:00:01Z"
335
+ )
336
+ page2 = MagicMock()
337
+ page2.observability_traces = _make_gql_page(traces=[_SAMPLE_OTLP_TRACE], has_more=False)
338
+ gql_client.submit.side_effect = [page1, page2]
339
+
340
+ mock_op = MagicMock()
341
+ gql_client.query.return_value = mock_op
342
+
343
+ list(obs.iter_traces("2026-05-01T00:00:00Z"))
344
+
345
+ calls = mock_op.observability_traces.call_args_list
346
+ assert len(calls) == 2
347
+ assert calls[0].kwargs["since"] == "2026-05-01T00:00:00Z"
348
+ assert calls[1].kwargs["since"] == "2026-05-01T12:00:01Z"
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Contract: SDK faithfully passes server-assembled OTLP through (ties both PRs)
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _load_golden_trace():
356
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "example_otlp_trace.json")
357
+ with open(path) as f:
358
+ return json.load(f)
359
+
360
+
361
+ def test_contract_reconstructs_real_server_trace_to_identical_otlp():
362
+ config, gql_client, obs = _make_client()
363
+ golden = _load_golden_trace()
364
+ mock_result = MagicMock()
365
+ mock_result.observability_traces = _make_gql_page(traces=[golden], count=1, has_more=False)
366
+ gql_client.submit.return_value = mock_result
367
+
368
+ batch = obs.get_traces("2026-05-01T00:00:00Z")
369
+
370
+ assert batch.success is True
371
+ assert batch.count == 1
372
+ # The typed span tree must reconstruct byte-identically to the server's OTLP.
373
+ assert batch.traces[0] == golden
374
+
375
+
376
+ def test_reconstruction_handles_typed_attributes_and_optionals():
377
+ from answer_rocket.observability import _span_to_otlp
378
+ full = _typed_span({
379
+ "traceId": "t", "spanId": "s", "name": "n", "kind": "SPAN_KIND_INTERNAL",
380
+ "startTimeUnixNano": "1", "endTimeUnixNano": "2",
381
+ "attributes": [
382
+ {"key": "ar.run_id", "value": {"stringValue": "v"}},
383
+ {"key": "ar.tokens.total", "value": {"intValue": "42"}},
384
+ {"key": "ar.cost", "value": {"doubleValue": 0.01}},
385
+ {"key": "ar.flag", "value": {"boolValue": True}},
386
+ {"key": "ar.answer.suggestions", "value": {"arrayValue": {"values": [{"stringValue": "a"}, {"stringValue": "b"}]}}},
387
+ ],
388
+ "events": [{"name": "e", "attributes": []}],
389
+ "parentSpanId": "p", "status": {"code": "STATUS_CODE_OK"},
390
+ })
391
+ assert _span_to_otlp(full) == {
392
+ "traceId": "t", "spanId": "s", "name": "n", "kind": "SPAN_KIND_INTERNAL",
393
+ "startTimeUnixNano": "1", "endTimeUnixNano": "2",
394
+ "attributes": [
395
+ {"key": "ar.run_id", "value": {"stringValue": "v"}},
396
+ {"key": "ar.tokens.total", "value": {"intValue": "42"}},
397
+ {"key": "ar.cost", "value": {"doubleValue": 0.01}},
398
+ {"key": "ar.flag", "value": {"boolValue": True}},
399
+ {"key": "ar.answer.suggestions", "value": {"arrayValue": {"values": [{"stringValue": "a"}, {"stringValue": "b"}]}}},
400
+ ],
401
+ "events": [{"name": "e", "attributes": []}],
402
+ "parentSpanId": "p", "status": {"code": "STATUS_CODE_OK"},
403
+ }
404
+
405
+ # Optional parentSpanId/status are omitted when absent; events is always a (possibly empty) list.
406
+ minimal = _typed_span({
407
+ "traceId": "t", "spanId": "s", "name": "root", "kind": "SPAN_KIND_SERVER",
408
+ "startTimeUnixNano": "1", "endTimeUnixNano": "2", "attributes": [], "events": [],
409
+ })
410
+ out = _span_to_otlp(minimal)
411
+ assert "parentSpanId" not in out
412
+ assert "status" not in out
413
+ assert out["events"] == []
414
+ assert out["attributes"] == []
415
+
416
+
417
+ def test_contract_returned_trace_is_valid_otlp():
418
+ config, gql_client, obs = _make_client()
419
+ golden = _load_golden_trace()
420
+ mock_result = MagicMock()
421
+ mock_result.observability_traces = _make_gql_page(traces=[golden], count=1)
422
+ gql_client.submit.return_value = mock_result
423
+
424
+ trace = obs.get_traces("2026-05-01T00:00:00Z").traces[0]
425
+
426
+ spans = trace["resourceSpans"][0]["scopeSpans"][0]["spans"]
427
+ names = [s["name"] for s in spans]
428
+ assert "chat.pipeline" in names
429
+ assert any(n.startswith("phase.") for n in names)
430
+ assert "skill.narrative" in names
431
+ # All spans share one traceId; every span has the required OTLP fields.
432
+ trace_ids = {s["traceId"] for s in spans}
433
+ assert len(trace_ids) == 1
434
+ for s in spans:
435
+ assert s["spanId"]
436
+ assert s["startTimeUnixNano"] and s["endTimeUnixNano"]
437
+ assert int(s["startTimeUnixNano"]) <= int(s["endTimeUnixNano"])