mirascope 2.0.0a1__py3-none-any.whl → 2.0.0a3__py3-none-any.whl

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 (205) hide show
  1. mirascope/__init__.py +2 -2
  2. mirascope/api/__init__.py +6 -0
  3. mirascope/api/_generated/README.md +207 -0
  4. mirascope/api/_generated/__init__.py +85 -0
  5. mirascope/api/_generated/client.py +155 -0
  6. mirascope/api/_generated/core/__init__.py +52 -0
  7. mirascope/api/_generated/core/api_error.py +23 -0
  8. mirascope/api/_generated/core/client_wrapper.py +58 -0
  9. mirascope/api/_generated/core/datetime_utils.py +30 -0
  10. mirascope/api/_generated/core/file.py +70 -0
  11. mirascope/api/_generated/core/force_multipart.py +16 -0
  12. mirascope/api/_generated/core/http_client.py +619 -0
  13. mirascope/api/_generated/core/http_response.py +55 -0
  14. mirascope/api/_generated/core/jsonable_encoder.py +102 -0
  15. mirascope/api/_generated/core/pydantic_utilities.py +310 -0
  16. mirascope/api/_generated/core/query_encoder.py +60 -0
  17. mirascope/api/_generated/core/remove_none_from_dict.py +11 -0
  18. mirascope/api/_generated/core/request_options.py +35 -0
  19. mirascope/api/_generated/core/serialization.py +282 -0
  20. mirascope/api/_generated/docs/__init__.py +4 -0
  21. mirascope/api/_generated/docs/client.py +95 -0
  22. mirascope/api/_generated/docs/raw_client.py +132 -0
  23. mirascope/api/_generated/environment.py +9 -0
  24. mirascope/api/_generated/errors/__init__.py +7 -0
  25. mirascope/api/_generated/errors/bad_request_error.py +15 -0
  26. mirascope/api/_generated/health/__init__.py +7 -0
  27. mirascope/api/_generated/health/client.py +96 -0
  28. mirascope/api/_generated/health/raw_client.py +129 -0
  29. mirascope/api/_generated/health/types/__init__.py +8 -0
  30. mirascope/api/_generated/health/types/health_check_response.py +24 -0
  31. mirascope/api/_generated/health/types/health_check_response_status.py +5 -0
  32. mirascope/api/_generated/reference.md +167 -0
  33. mirascope/api/_generated/traces/__init__.py +55 -0
  34. mirascope/api/_generated/traces/client.py +162 -0
  35. mirascope/api/_generated/traces/raw_client.py +168 -0
  36. mirascope/api/_generated/traces/types/__init__.py +95 -0
  37. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item.py +36 -0
  38. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource.py +31 -0
  39. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource_attributes_item.py +25 -0
  40. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource_attributes_item_value.py +54 -0
  41. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource_attributes_item_value_array_value.py +23 -0
  42. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource_attributes_item_value_kvlist_value.py +28 -0
  43. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_resource_attributes_item_value_kvlist_value_values_item.py +24 -0
  44. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item.py +35 -0
  45. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope.py +35 -0
  46. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope_attributes_item.py +27 -0
  47. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope_attributes_item_value.py +54 -0
  48. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope_attributes_item_value_array_value.py +23 -0
  49. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope_attributes_item_value_kvlist_value.py +28 -0
  50. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_scope_attributes_item_value_kvlist_value_values_item.py +24 -0
  51. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item.py +60 -0
  52. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_attributes_item.py +29 -0
  53. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_attributes_item_value.py +54 -0
  54. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_attributes_item_value_array_value.py +23 -0
  55. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_attributes_item_value_kvlist_value.py +28 -0
  56. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_attributes_item_value_kvlist_value_values_item.py +24 -0
  57. mirascope/api/_generated/traces/types/traces_create_request_resource_spans_item_scope_spans_item_spans_item_status.py +24 -0
  58. mirascope/api/_generated/traces/types/traces_create_response.py +27 -0
  59. mirascope/api/_generated/traces/types/traces_create_response_partial_success.py +28 -0
  60. mirascope/api/_generated/types/__init__.py +21 -0
  61. mirascope/api/_generated/types/http_api_decode_error.py +31 -0
  62. mirascope/api/_generated/types/http_api_decode_error_tag.py +5 -0
  63. mirascope/api/_generated/types/issue.py +44 -0
  64. mirascope/api/_generated/types/issue_tag.py +17 -0
  65. mirascope/api/_generated/types/property_key.py +7 -0
  66. mirascope/api/_generated/types/property_key_tag.py +29 -0
  67. mirascope/api/_generated/types/property_key_tag_tag.py +5 -0
  68. mirascope/api/client.py +255 -0
  69. mirascope/api/settings.py +81 -0
  70. mirascope/llm/__init__.py +41 -11
  71. mirascope/llm/calls/calls.py +81 -57
  72. mirascope/llm/calls/decorator.py +121 -115
  73. mirascope/llm/content/__init__.py +3 -2
  74. mirascope/llm/context/_utils.py +19 -6
  75. mirascope/llm/exceptions.py +30 -16
  76. mirascope/llm/formatting/_utils.py +9 -5
  77. mirascope/llm/formatting/format.py +2 -2
  78. mirascope/llm/formatting/from_call_args.py +2 -2
  79. mirascope/llm/messages/message.py +13 -5
  80. mirascope/llm/models/__init__.py +2 -2
  81. mirascope/llm/models/models.py +189 -81
  82. mirascope/llm/prompts/__init__.py +13 -12
  83. mirascope/llm/prompts/_utils.py +27 -24
  84. mirascope/llm/prompts/decorator.py +133 -204
  85. mirascope/llm/prompts/prompts.py +424 -0
  86. mirascope/llm/prompts/protocols.py +25 -59
  87. mirascope/llm/providers/__init__.py +38 -0
  88. mirascope/llm/{clients → providers}/_missing_import_stubs.py +8 -6
  89. mirascope/llm/providers/anthropic/__init__.py +24 -0
  90. mirascope/llm/{clients → providers}/anthropic/_utils/decode.py +5 -4
  91. mirascope/llm/{clients → providers}/anthropic/_utils/encode.py +31 -10
  92. mirascope/llm/providers/anthropic/model_id.py +40 -0
  93. mirascope/llm/{clients/anthropic/clients.py → providers/anthropic/provider.py} +33 -418
  94. mirascope/llm/{clients → providers}/base/__init__.py +3 -3
  95. mirascope/llm/{clients → providers}/base/_utils.py +10 -7
  96. mirascope/llm/{clients/base/client.py → providers/base/base_provider.py} +255 -126
  97. mirascope/llm/providers/google/__init__.py +21 -0
  98. mirascope/llm/{clients → providers}/google/_utils/decode.py +6 -4
  99. mirascope/llm/{clients → providers}/google/_utils/encode.py +30 -24
  100. mirascope/llm/providers/google/model_id.py +28 -0
  101. mirascope/llm/providers/google/provider.py +438 -0
  102. mirascope/llm/providers/load_provider.py +48 -0
  103. mirascope/llm/providers/mlx/__init__.py +24 -0
  104. mirascope/llm/providers/mlx/_utils.py +107 -0
  105. mirascope/llm/providers/mlx/encoding/__init__.py +8 -0
  106. mirascope/llm/providers/mlx/encoding/base.py +69 -0
  107. mirascope/llm/providers/mlx/encoding/transformers.py +131 -0
  108. mirascope/llm/providers/mlx/mlx.py +237 -0
  109. mirascope/llm/providers/mlx/model_id.py +17 -0
  110. mirascope/llm/providers/mlx/provider.py +411 -0
  111. mirascope/llm/providers/model_id.py +16 -0
  112. mirascope/llm/providers/openai/__init__.py +6 -0
  113. mirascope/llm/providers/openai/completions/__init__.py +20 -0
  114. mirascope/llm/{clients/openai/responses → providers/openai/completions}/_utils/__init__.py +2 -0
  115. mirascope/llm/{clients → providers}/openai/completions/_utils/decode.py +5 -3
  116. mirascope/llm/{clients → providers}/openai/completions/_utils/encode.py +33 -23
  117. mirascope/llm/providers/openai/completions/provider.py +456 -0
  118. mirascope/llm/providers/openai/model_id.py +31 -0
  119. mirascope/llm/providers/openai/model_info.py +246 -0
  120. mirascope/llm/providers/openai/provider.py +386 -0
  121. mirascope/llm/providers/openai/responses/__init__.py +21 -0
  122. mirascope/llm/{clients → providers}/openai/responses/_utils/decode.py +5 -3
  123. mirascope/llm/{clients → providers}/openai/responses/_utils/encode.py +28 -17
  124. mirascope/llm/providers/openai/responses/provider.py +470 -0
  125. mirascope/llm/{clients → providers}/openai/shared/_utils.py +7 -3
  126. mirascope/llm/providers/provider_id.py +13 -0
  127. mirascope/llm/providers/provider_registry.py +167 -0
  128. mirascope/llm/responses/base_response.py +10 -5
  129. mirascope/llm/responses/base_stream_response.py +10 -5
  130. mirascope/llm/responses/response.py +24 -13
  131. mirascope/llm/responses/root_response.py +7 -12
  132. mirascope/llm/responses/stream_response.py +35 -23
  133. mirascope/llm/tools/__init__.py +9 -2
  134. mirascope/llm/tools/_utils.py +12 -3
  135. mirascope/llm/tools/decorator.py +10 -10
  136. mirascope/llm/tools/protocols.py +4 -4
  137. mirascope/llm/tools/tool_schema.py +44 -9
  138. mirascope/llm/tools/tools.py +12 -11
  139. mirascope/ops/__init__.py +156 -0
  140. mirascope/ops/_internal/__init__.py +5 -0
  141. mirascope/ops/_internal/closure.py +1118 -0
  142. mirascope/ops/_internal/configuration.py +126 -0
  143. mirascope/ops/_internal/context.py +76 -0
  144. mirascope/ops/_internal/exporters/__init__.py +26 -0
  145. mirascope/ops/_internal/exporters/exporters.py +342 -0
  146. mirascope/ops/_internal/exporters/processors.py +104 -0
  147. mirascope/ops/_internal/exporters/types.py +165 -0
  148. mirascope/ops/_internal/exporters/utils.py +29 -0
  149. mirascope/ops/_internal/instrumentation/__init__.py +8 -0
  150. mirascope/ops/_internal/instrumentation/llm/__init__.py +8 -0
  151. mirascope/ops/_internal/instrumentation/llm/encode.py +238 -0
  152. mirascope/ops/_internal/instrumentation/llm/gen_ai_types/__init__.py +38 -0
  153. mirascope/ops/_internal/instrumentation/llm/gen_ai_types/gen_ai_input_messages.py +31 -0
  154. mirascope/ops/_internal/instrumentation/llm/gen_ai_types/gen_ai_output_messages.py +38 -0
  155. mirascope/ops/_internal/instrumentation/llm/gen_ai_types/gen_ai_system_instructions.py +18 -0
  156. mirascope/ops/_internal/instrumentation/llm/gen_ai_types/shared.py +100 -0
  157. mirascope/ops/_internal/instrumentation/llm/llm.py +1288 -0
  158. mirascope/ops/_internal/propagation.py +198 -0
  159. mirascope/ops/_internal/protocols.py +51 -0
  160. mirascope/ops/_internal/session.py +139 -0
  161. mirascope/ops/_internal/spans.py +232 -0
  162. mirascope/ops/_internal/traced_calls.py +371 -0
  163. mirascope/ops/_internal/traced_functions.py +394 -0
  164. mirascope/ops/_internal/tracing.py +276 -0
  165. mirascope/ops/_internal/types.py +13 -0
  166. mirascope/ops/_internal/utils.py +75 -0
  167. mirascope/ops/_internal/versioned_calls.py +512 -0
  168. mirascope/ops/_internal/versioned_functions.py +346 -0
  169. mirascope/ops/_internal/versioning.py +303 -0
  170. mirascope/ops/exceptions.py +21 -0
  171. {mirascope-2.0.0a1.dist-info → mirascope-2.0.0a3.dist-info}/METADATA +77 -1
  172. mirascope-2.0.0a3.dist-info/RECORD +206 -0
  173. {mirascope-2.0.0a1.dist-info → mirascope-2.0.0a3.dist-info}/WHEEL +1 -1
  174. mirascope/graphs/__init__.py +0 -22
  175. mirascope/graphs/finite_state_machine.py +0 -625
  176. mirascope/llm/agents/__init__.py +0 -15
  177. mirascope/llm/agents/agent.py +0 -97
  178. mirascope/llm/agents/agent_template.py +0 -45
  179. mirascope/llm/agents/decorator.py +0 -176
  180. mirascope/llm/calls/base_call.py +0 -33
  181. mirascope/llm/clients/__init__.py +0 -34
  182. mirascope/llm/clients/anthropic/__init__.py +0 -25
  183. mirascope/llm/clients/anthropic/model_ids.py +0 -8
  184. mirascope/llm/clients/google/__init__.py +0 -20
  185. mirascope/llm/clients/google/clients.py +0 -853
  186. mirascope/llm/clients/google/model_ids.py +0 -15
  187. mirascope/llm/clients/openai/__init__.py +0 -25
  188. mirascope/llm/clients/openai/completions/__init__.py +0 -28
  189. mirascope/llm/clients/openai/completions/_utils/model_features.py +0 -81
  190. mirascope/llm/clients/openai/completions/clients.py +0 -833
  191. mirascope/llm/clients/openai/completions/model_ids.py +0 -8
  192. mirascope/llm/clients/openai/responses/__init__.py +0 -26
  193. mirascope/llm/clients/openai/responses/_utils/model_features.py +0 -87
  194. mirascope/llm/clients/openai/responses/clients.py +0 -832
  195. mirascope/llm/clients/openai/responses/model_ids.py +0 -8
  196. mirascope/llm/clients/providers.py +0 -175
  197. mirascope-2.0.0a1.dist-info/RECORD +0 -102
  198. /mirascope/llm/{clients → providers}/anthropic/_utils/__init__.py +0 -0
  199. /mirascope/llm/{clients → providers}/base/kwargs.py +0 -0
  200. /mirascope/llm/{clients → providers}/base/params.py +0 -0
  201. /mirascope/llm/{clients → providers}/google/_utils/__init__.py +0 -0
  202. /mirascope/llm/{clients → providers}/google/message.py +0 -0
  203. /mirascope/llm/{clients/openai/completions → providers/openai/responses}/_utils/__init__.py +0 -0
  204. /mirascope/llm/{clients → providers}/openai/shared/__init__.py +0 -0
  205. {mirascope-2.0.0a1.dist-info → mirascope-2.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,126 @@
1
+ """Configuration utilities for Mirascope ops module initialization and setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from opentelemetry.sdk.trace import TracerProvider
11
+ from opentelemetry.trace import Tracer
12
+ else:
13
+ Tracer = None
14
+
15
+ DEFAULT_TRACER_NAME = "mirascope.llm"
16
+
17
+ try:
18
+ from opentelemetry import trace as otel_trace
19
+ except ImportError: # pragma: no cover
20
+ otel_trace = None
21
+
22
+ _tracer_provider: TracerProvider | None = None
23
+ _tracer_name: str = DEFAULT_TRACER_NAME
24
+ _tracer_version: str | None = None
25
+ _tracer: Tracer | None = None
26
+
27
+
28
+ def configure(
29
+ *,
30
+ tracer_provider: TracerProvider | None = None,
31
+ tracer_name: str = DEFAULT_TRACER_NAME,
32
+ tracer_version: str | None = None,
33
+ ) -> None:
34
+ """Configure the ops module defaults for tracing.
35
+
36
+ Sets up default tracer settings for the ops module. If a tracer_provider
37
+ is supplied, it will be installed as the global OpenTelemetry tracer provider.
38
+
39
+ Args:
40
+ tracer_provider: Optional OpenTelemetry TracerProvider to use as default
41
+ and to install globally.
42
+ tracer_name: Tracer name to use when creating a tracer.
43
+ Defaults to "mirascope.llm".
44
+ tracer_version: Optional tracer version.
45
+
46
+ Example:
47
+
48
+ Configure custom tracer settings:
49
+ ```python
50
+ from mirascope import ops
51
+ from opentelemetry.sdk.trace import TracerProvider
52
+
53
+ provider = TracerProvider()
54
+ ops.configure(tracer_provider=provider)
55
+ ops.instrument_llm()
56
+ ```
57
+ """
58
+ # TODO: refactor alongside other import error handling improvements
59
+ if otel_trace is None: # pragma: no cover
60
+ raise ImportError(
61
+ "OpenTelemetry is not installed. Run `pip install mirascope[otel]` "
62
+ "before calling `ops.configure(tracer_provider=...)`."
63
+ )
64
+
65
+ global _tracer_provider, _tracer_name, _tracer_version, _tracer
66
+
67
+ if tracer_provider is not None:
68
+ _tracer_provider = tracer_provider
69
+ otel_trace.set_tracer_provider(tracer_provider)
70
+
71
+ _tracer_name = tracer_name
72
+ _tracer_version = tracer_version
73
+
74
+ if otel_trace is not None:
75
+ provider = (
76
+ otel_trace.get_tracer_provider()
77
+ if _tracer_provider is None
78
+ else _tracer_provider
79
+ )
80
+ _tracer = provider.get_tracer(_tracer_name, _tracer_version)
81
+
82
+
83
+ def set_tracer(tracer: Tracer | None) -> None:
84
+ """Set the configured tracer instance."""
85
+ global _tracer
86
+ _tracer = tracer
87
+
88
+
89
+ def get_tracer() -> Tracer | None:
90
+ """Return the configured tracer instance."""
91
+ return _tracer
92
+
93
+
94
+ @contextmanager
95
+ def tracer_context(tracer: Tracer | None) -> Iterator[Tracer | None]:
96
+ """Context manager for temporarily setting a tracer.
97
+
98
+ Temporarily sets the tracer for the duration of the context and restores
99
+ the previous tracer when the context exits.
100
+
101
+ Args:
102
+ tracer: The tracer to use within the context.
103
+
104
+ Yields:
105
+ The tracer that was set.
106
+
107
+ Example:
108
+ ```python
109
+ from mirascope import ops
110
+ from opentelemetry.sdk.trace import TracerProvider
111
+
112
+ provider = TracerProvider()
113
+ tracer = provider.get_tracer("my-tracer")
114
+
115
+ with ops.tracer_context(tracer):
116
+ # Use the tracer within this context
117
+ ...
118
+ # Previous tracer is restored here
119
+ ```
120
+ """
121
+ previous_tracer = get_tracer()
122
+ set_tracer(tracer)
123
+ try:
124
+ yield tracer
125
+ finally:
126
+ set_tracer(previous_tracer)
@@ -0,0 +1,76 @@
1
+ """Context management utilities for distributed tracing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from contextlib import ExitStack, contextmanager
7
+ from typing import Any
8
+
9
+ from opentelemetry import context as otel_context
10
+ from opentelemetry.context import Context
11
+
12
+ from .propagation import extract_context
13
+ from .session import extract_session_id, session
14
+
15
+
16
+ @contextmanager
17
+ def propagated_context(
18
+ *,
19
+ parent: Context | None = None,
20
+ extract_from: Mapping[str, Any] | None = None,
21
+ ) -> Iterator[None]:
22
+ """Attach a parent context or extract context from carrier headers.
23
+
24
+ This context manager is used to establish trace context continuity,
25
+ typically on the server side when receiving requests. It either extracts
26
+ context from incoming headers or attaches a pre-existing context.
27
+
28
+ Args:
29
+ parent: Pre-existing OTEL context to attach. Mutually exclusive with extract_from.
30
+ extract_from: Dictionary of headers to extract context from (e.g., request.headers).
31
+ Mutually exclusive with parent.
32
+
33
+ Raises:
34
+ ValueError: If both parent and extract_from are provided, or if neither is provided.
35
+
36
+ Example:
37
+ Server-side context extraction from FastAPI request:
38
+
39
+ ```python
40
+ @app.post("/endpoint")
41
+ async def endpoint(request: Request):
42
+ with propagated_context(extract_from=dict(request.headers)):
43
+ result = process_request()
44
+ return result
45
+ ```
46
+
47
+ Using a pre-existing context:
48
+
49
+ ```python
50
+ with propagated_context(parent=existing_context):
51
+ do_work()
52
+ ```
53
+ """
54
+ if parent is not None and extract_from is not None:
55
+ raise ValueError("Cannot specify both 'parent' and 'extract_from' parameters")
56
+
57
+ if parent is None and extract_from is None:
58
+ raise ValueError("Must specify either 'parent' or 'extract_from' parameter")
59
+
60
+ if extract_from is not None:
61
+ with ExitStack() as stack:
62
+ session_id = extract_session_id(extract_from)
63
+ if session_id:
64
+ stack.enter_context(session(id=session_id))
65
+
66
+ extracted_context = extract_context(extract_from)
67
+ token = otel_context.attach(extracted_context)
68
+ stack.callback(otel_context.detach, token)
69
+
70
+ yield
71
+ elif parent is not None:
72
+ token = otel_context.attach(parent)
73
+ try:
74
+ yield
75
+ finally:
76
+ otel_context.detach(token)
@@ -0,0 +1,26 @@
1
+ """Mirascope OpenTelemetry exporters for two-phase telemetry.
2
+
3
+ This package provides a two-phase export system for OpenTelemetry tracing:
4
+ 1. Immediate start event transmission for real-time visibility
5
+ 2. Batched end event transmission for efficiency
6
+ """
7
+
8
+ from .exporters import MirascopeOTLPExporter
9
+ from .processors import MirascopeSpanProcessor
10
+ from .types import (
11
+ Link,
12
+ SpanContextDict,
13
+ SpanEvent,
14
+ SpanEventType,
15
+ Status,
16
+ )
17
+
18
+ __all__ = [
19
+ "Link",
20
+ "MirascopeOTLPExporter",
21
+ "MirascopeSpanProcessor",
22
+ "SpanContextDict",
23
+ "SpanEvent",
24
+ "SpanEventType",
25
+ "Status",
26
+ ]
@@ -0,0 +1,342 @@
1
+ """Exporter implementation for OpenTelemetry exporters.
2
+
3
+ This module provides the export layer for sending OpenTelemetry span
4
+ events to the Mirascope ingestion endpoint. It wraps the Fern-generated
5
+ Mirascope client to provide the interface needed by OpenTelemetry exporters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from collections.abc import Sequence
13
+
14
+ from opentelemetry.sdk.trace import ReadableSpan
15
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
16
+ from opentelemetry.util.types import AttributeValue
17
+
18
+ from ....api._generated.traces.types import (
19
+ TracesCreateRequestResourceSpansItem,
20
+ TracesCreateRequestResourceSpansItemResource,
21
+ TracesCreateRequestResourceSpansItemResourceAttributesItem,
22
+ TracesCreateRequestResourceSpansItemResourceAttributesItemValue,
23
+ TracesCreateRequestResourceSpansItemScopeSpansItem,
24
+ TracesCreateRequestResourceSpansItemScopeSpansItemScope,
25
+ TracesCreateRequestResourceSpansItemScopeSpansItemSpansItem,
26
+ TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItem,
27
+ TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue,
28
+ TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemStatus,
29
+ )
30
+ from ....api.client import Mirascope
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class MirascopeOTLPExporter(SpanExporter):
36
+ """OTLP/HTTP exporter for completed spans.
37
+
38
+ This exporter implements the OpenTelemetry SpanExporter interface
39
+ for exporting completed spans in OTLP format over HTTP. It's
40
+ designed to work with BatchSpanProcessor for efficient batching.
41
+
42
+ This uses the Fern auto-generated client for sending converted spans.
43
+
44
+ Attributes:
45
+ exporter: Export client for sending events.
46
+ timeout: Request timeout in seconds.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ client: Mirascope,
52
+ timeout: float = 30.0,
53
+ max_retry_attempts: int = 3,
54
+ ) -> None:
55
+ """Initialize the telemetry exporter.
56
+
57
+ Args:
58
+ client: The Fern-generated Mirascope client instance.
59
+ In the future, this will accept the enhanced client from
60
+ mirascope.api.client that provides error handling and caching
61
+ capabilities.
62
+ timeout: Request timeout in seconds for telemetry operations.
63
+ max_retry_attempts: Maximum number of retry attempts for failed exports.
64
+ """
65
+ self.client = client
66
+ self.timeout = timeout
67
+ self.max_retry_attempts = max_retry_attempts
68
+ self._shutdown = False
69
+
70
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
71
+ """Export a batch of spans to the telemetry endpoint.
72
+
73
+ This is the standard OpenTelemetry export interface.
74
+
75
+ Args:
76
+ spans: Sequence of ReadableSpan objects to export.
77
+
78
+ Returns:
79
+ SpanExportResult indicating success or failure.
80
+ """
81
+ if self._shutdown:
82
+ return SpanExportResult.FAILURE
83
+
84
+ if not spans:
85
+ return SpanExportResult.SUCCESS
86
+
87
+ exceptions: list[Exception] = []
88
+ delay = 0.1
89
+
90
+ for i in range(self.max_retry_attempts):
91
+ if i > 0:
92
+ time.sleep(delay)
93
+ delay = min(delay * 2, 5.0)
94
+
95
+ try:
96
+ otlp_data = self._convert_spans_to_otlp(spans)
97
+ response = self.client.traces.create(resource_spans=otlp_data)
98
+
99
+ if (
100
+ response
101
+ and hasattr(response, "partial_success")
102
+ and response.partial_success
103
+ ):
104
+ partial_success = response.partial_success
105
+ if hasattr(partial_success, "rejected_spans"):
106
+ rejected = partial_success.rejected_spans
107
+ if rejected is not None and rejected > 0:
108
+ return SpanExportResult.FAILURE
109
+
110
+ return SpanExportResult.SUCCESS
111
+
112
+ except Exception as e:
113
+ exceptions.append(e)
114
+ logger.warning(
115
+ f"Export attempt {i + 1} failed, retrying in {delay}s: {e}"
116
+ )
117
+
118
+ logger.error(
119
+ f"Failed to export spans after {self.max_retry_attempts} attempts: {exceptions}"
120
+ )
121
+
122
+ return SpanExportResult.FAILURE
123
+
124
+ def _convert_spans_to_otlp(
125
+ self, spans: Sequence[ReadableSpan]
126
+ ) -> list[TracesCreateRequestResourceSpansItem]:
127
+ """Convert OpenTelemetry spans to OTLP format.
128
+
129
+ Args:
130
+ spans: Sequence of ReadableSpan objects.
131
+
132
+ Returns:
133
+ List of ResourceSpans in OTLP format.
134
+ """
135
+ resource_spans_map = {}
136
+
137
+ for span in spans:
138
+ try:
139
+ otlp_span = self._convert_span(span)
140
+ except ValueError as e:
141
+ logger.warning(f"Skipping span due to error: {e}")
142
+ continue
143
+
144
+ resource_key = id(span.resource) if span.resource else "default"
145
+
146
+ if resource_key not in resource_spans_map:
147
+ resource = None
148
+ if span.resource:
149
+ resource_attrs = []
150
+ for key, value in span.resource.attributes.items():
151
+ attr_value = self._convert_resource_attribute_value(value)
152
+ resource_attrs.append(
153
+ TracesCreateRequestResourceSpansItemResourceAttributesItem(
154
+ key=key,
155
+ value=attr_value,
156
+ )
157
+ )
158
+ resource = TracesCreateRequestResourceSpansItemResource(
159
+ attributes=resource_attrs
160
+ )
161
+
162
+ resource_spans_map[resource_key] = {
163
+ "resource": resource,
164
+ "scope_spans": {},
165
+ }
166
+
167
+ scope_key = (
168
+ span.instrumentation_scope.name
169
+ if span.instrumentation_scope
170
+ else "unknown"
171
+ )
172
+
173
+ if scope_key not in resource_spans_map[resource_key]["scope_spans"]:
174
+ scope = None
175
+ if span.instrumentation_scope:
176
+ scope = TracesCreateRequestResourceSpansItemScopeSpansItemScope(
177
+ name=span.instrumentation_scope.name,
178
+ version=span.instrumentation_scope.version,
179
+ )
180
+
181
+ resource_spans_map[resource_key]["scope_spans"][scope_key] = {
182
+ "scope": scope,
183
+ "spans": [],
184
+ }
185
+
186
+ resource_spans_map[resource_key]["scope_spans"][scope_key]["spans"].append(
187
+ otlp_span
188
+ )
189
+
190
+ result = []
191
+ for resource_data in resource_spans_map.values():
192
+ scope_spans = []
193
+ for scope_data in resource_data["scope_spans"].values():
194
+ scope_spans.append(
195
+ TracesCreateRequestResourceSpansItemScopeSpansItem(
196
+ scope=scope_data["scope"],
197
+ spans=scope_data["spans"],
198
+ )
199
+ )
200
+
201
+ result.append(
202
+ TracesCreateRequestResourceSpansItem(
203
+ resource=resource_data["resource"],
204
+ scope_spans=scope_spans,
205
+ )
206
+ )
207
+
208
+ return result
209
+
210
+ def _convert_span(
211
+ self, span: ReadableSpan
212
+ ) -> TracesCreateRequestResourceSpansItemScopeSpansItemSpansItem:
213
+ """Convert a single ReadableSpan to OTLP format."""
214
+ context = span.get_span_context()
215
+ if not context or not context.is_valid:
216
+ raise ValueError(f"Cannot export span without valid context: {span.name}")
217
+
218
+ attributes = []
219
+ if span.attributes:
220
+ for key, value in span.attributes.items():
221
+ attr_value = self._convert_attribute_value(value)
222
+ attributes.append(
223
+ TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItem(
224
+ key=key,
225
+ value=attr_value,
226
+ )
227
+ )
228
+
229
+ status = None
230
+ if span.status:
231
+ status = TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemStatus(
232
+ code=span.status.status_code.value,
233
+ message=span.status.description or "",
234
+ )
235
+
236
+ trace_id = format(context.trace_id, "032x")
237
+ span_id = format(context.span_id, "016x")
238
+
239
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItem(
240
+ trace_id=trace_id,
241
+ span_id=span_id,
242
+ parent_span_id=(
243
+ format(span.parent.span_id, "016x")
244
+ if span.parent and span.parent.span_id
245
+ else None
246
+ ),
247
+ name=span.name,
248
+ kind=span.kind.value if span.kind else 0,
249
+ start_time_unix_nano=str(span.start_time) if span.start_time else "0",
250
+ end_time_unix_nano=str(span.end_time) if span.end_time else "0",
251
+ attributes=attributes or None,
252
+ status=status,
253
+ )
254
+
255
+ def _convert_attribute_value(
256
+ self, value: AttributeValue
257
+ ) -> TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue:
258
+ """Convert OpenTelemetry AttributeValue to Mirascope API's KeyValueValue.
259
+
260
+ This conversion is necessary because the Fern-generated API client
261
+ expects KeyValueValue objects, not OpenTelemetry's AttributeValue types.
262
+
263
+ Args:
264
+ value: An OpenTelemetry AttributeValue (bool, int, float, str, or Sequence)
265
+
266
+ Returns:
267
+ A KeyValueValue object for the Mirascope API
268
+ """
269
+ match value:
270
+ case str():
271
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue(
272
+ string_value=value
273
+ )
274
+ case bool():
275
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue(
276
+ bool_value=value
277
+ )
278
+ case int():
279
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue(
280
+ int_value=str(value)
281
+ )
282
+ case float():
283
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue(
284
+ double_value=value
285
+ )
286
+ case _:
287
+ return TracesCreateRequestResourceSpansItemScopeSpansItemSpansItemAttributesItemValue(
288
+ string_value=str(list(value))
289
+ )
290
+
291
+ def _convert_resource_attribute_value(
292
+ self, value: AttributeValue
293
+ ) -> TracesCreateRequestResourceSpansItemResourceAttributesItemValue:
294
+ """Convert OpenTelemetry AttributeValue to Mirascope API's resource KeyValueValue.
295
+
296
+ This conversion is necessary because the Fern-generated API client
297
+ expects KeyValueValue objects, not OpenTelemetry's AttributeValue types.
298
+
299
+ Args:
300
+ value: An OpenTelemetry AttributeValue (bool, int, float, str, or Sequence)
301
+
302
+ Returns:
303
+ A KeyValueValue object for the Mirascope API resource attributes
304
+ """
305
+ match value:
306
+ case str():
307
+ return TracesCreateRequestResourceSpansItemResourceAttributesItemValue(
308
+ string_value=value
309
+ )
310
+ case bool():
311
+ return TracesCreateRequestResourceSpansItemResourceAttributesItemValue(
312
+ bool_value=value
313
+ )
314
+ case int():
315
+ return TracesCreateRequestResourceSpansItemResourceAttributesItemValue(
316
+ int_value=str(value)
317
+ )
318
+ case float():
319
+ return TracesCreateRequestResourceSpansItemResourceAttributesItemValue(
320
+ double_value=value
321
+ )
322
+ case _:
323
+ return TracesCreateRequestResourceSpansItemResourceAttributesItemValue(
324
+ string_value=str(list(value))
325
+ )
326
+
327
+ def shutdown(self) -> None:
328
+ """Shutdown the exporter. Subsequent exports will return FAILURE."""
329
+ self._shutdown = True
330
+
331
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
332
+ """Force flush any pending data.
333
+
334
+ No-op since this exporter does not buffer data internally.
335
+
336
+ Args:
337
+ timeout_millis: Maximum time to wait in milliseconds (unused).
338
+
339
+ Returns:
340
+ Always True since there is no internal buffer to flush.
341
+ """
342
+ return True
@@ -0,0 +1,104 @@
1
+ """Span processors for two-phase export system.
2
+
3
+ This module implements a custom SpanProcessor that sends immediate
4
+ start events and batches end events for efficient export.
5
+ """
6
+
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from opentelemetry.context import Context
10
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
11
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
12
+
13
+ from .exporters import MirascopeOTLPExporter
14
+
15
+
16
+ class MirascopeSpanProcessor(SpanProcessor):
17
+ """Two-phase span processor for Mirascope telemetry.
18
+
19
+ This processor implements a two-phase export strategy:
20
+ 1. Immediate transmission of minimal start events for real-time visibility
21
+ 2. Batched transmission of complete events for efficiency
22
+
23
+ The processor uses a thread pool to ensure start events don't block
24
+ the application while maintaining compatibility with OpenTelemetry's
25
+ synchronous SDK.
26
+
27
+ Attributes:
28
+ start_exporter: Exporter for immediate start events.
29
+ batch_processor: Standard batch processor for completed spans.
30
+ executor: Thread pool for non-blocking start event transmission.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ otlp_exporter: MirascopeOTLPExporter,
36
+ batch_processor: BatchSpanProcessor | None = None,
37
+ executor: ThreadPoolExecutor | None = None,
38
+ ) -> None:
39
+ """Initialize the two-phase processor.
40
+
41
+ Args:
42
+ start_exporter: Exporter for immediate start events.
43
+ batch_processor: Optional batch processor for end events.
44
+ executor: Optional thread pool executor (creates default if None).
45
+ """
46
+ self.otlp_exporter = otlp_exporter
47
+ self.batch_processor = batch_processor
48
+ self.executor = executor or ThreadPoolExecutor(
49
+ max_workers=2, thread_name_prefix="mirascope-span-processor"
50
+ )
51
+ self._shutdown = False
52
+
53
+ def on_start(
54
+ self, span: ReadableSpan, parent_context: Context | None = None
55
+ ) -> None:
56
+ """Handle span start by sending immediate start event.
57
+
58
+ This method extracts minimal span data and sends it immediately
59
+ via the start exporter in a non-blocking manner.
60
+
61
+ Args:
62
+ span: The span that just started.
63
+ parent_context: Optional parent context for the span.
64
+ """
65
+ if self._shutdown:
66
+ return
67
+
68
+ self.executor.submit(self.otlp_exporter.export, [span])
69
+
70
+ def on_end(self, span: ReadableSpan) -> None:
71
+ """Handle span end by delegating to batch processor.
72
+
73
+ Args:
74
+ span: The span that just ended.
75
+ """
76
+ if self.batch_processor and not self._shutdown:
77
+ self.batch_processor.on_end(span)
78
+
79
+ def shutdown(self) -> None:
80
+ """Gracefully shutdown the processor.
81
+
82
+ This ensures all pending start events are sent and the
83
+ batch processor is properly shutdown.
84
+ """
85
+ self._shutdown = True
86
+
87
+ if self.batch_processor:
88
+ self.batch_processor.shutdown()
89
+
90
+ self.executor.shutdown(wait=True)
91
+ self.otlp_exporter.shutdown()
92
+
93
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
94
+ """Force flush all pending data.
95
+
96
+ Args:
97
+ timeout_millis: Maximum time to wait in milliseconds.
98
+
99
+ Returns:
100
+ True if flush completed successfully.
101
+ """
102
+ if self.batch_processor:
103
+ return self.batch_processor.force_flush(timeout_millis)
104
+ return True