Skip to content

Commit bb9a0cd

Browse files
authored
Move TestExporter to avoid requiring pytest (#368)
1 parent b10f6e9 commit bb9a0cd

File tree

8 files changed

+174
-156
lines changed

8 files changed

+174
-156
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Release Notes
22

3-
## [v0.50.0] (2024-08-06)
3+
## [v0.50.1] (2024-08-06)
4+
5+
(Previously released as `v0.50.0`, then yanked due to https://github.com/pydantic/logfire/issues/367)
46

57
* **BREAKING CHANGES:** Separate sending to Logfire from using standard OTEL environment variables by @alexmojaki in https://github.com/pydantic/logfire/pull/351. See https://docs.pydantic.dev/logfire/guides/advanced/alternative_backends/ for details. Highlights:
68
* `OTEL_EXPORTER_OTLP_ENDPOINT` is no longer just an alternative to `LOGFIRE_BASE_URL`. Setting `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, and/or `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` will set up appropriate exporters *in addition* to sending to Logfire, which must be turned off separately if desired. These are basic exporters relying on OTEL defaults. In particular they don't use our custom retrying logic.

logfire-api/logfire_api/_internal/config.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import dataclasses
22
import requests
3-
from ..testing import TestExporter as TestExporter
43
from .auth import DEFAULT_FILE as DEFAULT_FILE, DefaultFile as DefaultFile, is_logged_in as is_logged_in
54
from .collect_system_info import collect_package_info as collect_package_info
65
from .config_params import ParamManager as ParamManager, PydanticPluginRecordValues as PydanticPluginRecordValues
@@ -13,6 +12,7 @@ from .exporters.processor_wrapper import MainSpanProcessorWrapper as MainSpanPro
1312
from .exporters.quiet_metrics import QuietMetricExporter as QuietMetricExporter
1413
from .exporters.remove_pending import RemovePendingSpansExporter as RemovePendingSpansExporter
1514
from .exporters.tail_sampling import TailSamplingOptions as TailSamplingOptions, TailSamplingProcessor as TailSamplingProcessor
15+
from .exporters.test import TestExporter as TestExporter
1616
from .integrations.executors import instrument_executors as instrument_executors
1717
from .metrics import ProxyMeterProvider as ProxyMeterProvider, configure_metrics as configure_metrics
1818
from .scrubbing import BaseScrubber as BaseScrubber, NOOP_SCRUBBER as NOOP_SCRUBBER, ScrubCallback as ScrubCallback, Scrubber as Scrubber, ScrubbingOptions as ScrubbingOptions

logfire-api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logfire-api"
7-
version = "0.50.0"
7+
version = "0.50.1"
88
description = "Shim for the Logfire SDK which does nothing unless Logfire is installed"
99
authors = [
1010
{ name = "Pydantic Team", email = "[email protected]" },

logfire/_internal/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
from logfire.exceptions import LogfireConfigError
5353
from logfire.version import VERSION
5454

55-
from ..testing import TestExporter
5655
from .auth import DEFAULT_FILE, DefaultFile, is_logged_in
5756
from .collect_system_info import collect_package_info
5857
from .config_params import ParamManager, PydanticPluginRecordValues
@@ -75,6 +74,7 @@
7574
from .exporters.quiet_metrics import QuietMetricExporter
7675
from .exporters.remove_pending import RemovePendingSpansExporter
7776
from .exporters.tail_sampling import TailSamplingOptions, TailSamplingProcessor
77+
from .exporters.test import TestExporter
7878
from .integrations.executors import instrument_executors
7979
from .metrics import ProxyMeterProvider, configure_metrics
8080
from .scrubbing import NOOP_SCRUBBER, BaseScrubber, Scrubber, ScrubbingOptions, ScrubCallback
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
import sys
6+
from collections.abc import Sequence
7+
from pathlib import Path
8+
from typing import Any, Mapping, cast
9+
10+
from opentelemetry import trace
11+
from opentelemetry.sdk.trace import Event, ReadableSpan
12+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
13+
from opentelemetry.semconv.resource import ResourceAttributes
14+
from opentelemetry.semconv.trace import SpanAttributes
15+
16+
from ..constants import ATTRIBUTES_SPAN_TYPE_KEY, RESOURCE_ATTRIBUTES_PACKAGE_VERSIONS
17+
18+
19+
class TestExporter(SpanExporter):
20+
"""A SpanExporter that stores exported spans in a list for asserting in tests."""
21+
22+
# NOTE: Avoid test discovery by pytest.
23+
__test__ = False
24+
25+
def __init__(self) -> None:
26+
self.exported_spans: list[ReadableSpan] = []
27+
28+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
29+
"""Exports a batch of telemetry data."""
30+
self.exported_spans.extend(spans)
31+
return SpanExportResult.SUCCESS
32+
33+
def clear(self) -> None:
34+
"""Clears the collected spans."""
35+
self.exported_spans = []
36+
37+
def exported_spans_as_dict(
38+
self,
39+
fixed_line_number: int | None = 123,
40+
strip_filepaths: bool = True,
41+
include_resources: bool = False,
42+
include_package_versions: bool = False,
43+
include_instrumentation_scope: bool = False,
44+
_include_pending_spans: bool = False,
45+
_strip_function_qualname: bool = True,
46+
) -> list[dict[str, Any]]:
47+
"""The exported spans as a list of dicts.
48+
49+
Args:
50+
fixed_line_number: The line number to use for all spans.
51+
strip_filepaths: Whether to strip the filepaths from the exported spans.
52+
include_resources: Whether to include the resource attributes in the exported spans.
53+
include_package_versions: Whether to include the package versions in the exported spans.
54+
include_instrumentation_scope: Whether to include the instrumentation scope in the exported spans.
55+
56+
Returns:
57+
A list of dicts representing the exported spans.
58+
"""
59+
60+
def process_attribute(name: str, value: Any) -> Any:
61+
if name == 'code.filepath' and strip_filepaths:
62+
try:
63+
return Path(value).name
64+
except ValueError: # pragma: no cover
65+
return value
66+
if name == 'code.lineno' and fixed_line_number is not None:
67+
return fixed_line_number
68+
if name == 'code.function':
69+
if sys.version_info >= (3, 11) and _strip_function_qualname:
70+
return value.split('.')[-1]
71+
if name == ResourceAttributes.PROCESS_PID:
72+
assert value == os.getpid()
73+
return 1234
74+
if name == ResourceAttributes.SERVICE_INSTANCE_ID:
75+
if re.match(r'^[0-9a-f]{32}$', value):
76+
return '0' * 32
77+
return value
78+
79+
def build_attributes(attributes: Mapping[str, Any] | None) -> dict[str, Any] | None:
80+
if attributes is None: # pragma: no branch
81+
return None # pragma: no cover
82+
attributes = {
83+
k: process_attribute(k, v)
84+
for k, v in attributes.items()
85+
if k != RESOURCE_ATTRIBUTES_PACKAGE_VERSIONS or include_package_versions
86+
}
87+
if 'telemetry.sdk.version' in attributes:
88+
attributes['telemetry.sdk.version'] = '0.0.0'
89+
return attributes
90+
91+
def build_event(event: Event) -> dict[str, Any]:
92+
res: dict[str, Any] = {
93+
'name': event.name,
94+
'timestamp': event.timestamp,
95+
}
96+
if event.attributes: # pragma: no branch
97+
res['attributes'] = attributes = dict(event.attributes)
98+
if SpanAttributes.EXCEPTION_STACKTRACE in attributes:
99+
last_line = next( # pragma: no branch
100+
line.strip()
101+
for line in reversed(
102+
cast(str, event.attributes[SpanAttributes.EXCEPTION_STACKTRACE]).split('\n')
103+
)
104+
if line.strip()
105+
)
106+
attributes[SpanAttributes.EXCEPTION_STACKTRACE] = last_line
107+
return res
108+
109+
def build_instrumentation_scope(span: ReadableSpan) -> dict[str, Any]:
110+
if include_instrumentation_scope:
111+
return {'instrumentation_scope': span.instrumentation_scope and span.instrumentation_scope.name}
112+
else:
113+
return {}
114+
115+
def build_span(span: ReadableSpan) -> dict[str, Any]:
116+
context = span.context or trace.INVALID_SPAN_CONTEXT
117+
res: dict[str, Any] = {
118+
'name': span.name,
119+
'context': {
120+
'trace_id': context.trace_id,
121+
'span_id': context.span_id,
122+
'is_remote': context.is_remote,
123+
},
124+
'parent': {
125+
'trace_id': span.parent.trace_id,
126+
'span_id': span.parent.span_id,
127+
'is_remote': span.parent.is_remote,
128+
}
129+
if span.parent
130+
else None,
131+
'start_time': span.start_time,
132+
'end_time': span.end_time,
133+
**build_instrumentation_scope(span),
134+
'attributes': build_attributes(span.attributes),
135+
}
136+
if span.events:
137+
res['events'] = [build_event(event) for event in span.events]
138+
if include_resources:
139+
resource_attributes = build_attributes(span.resource.attributes)
140+
res['resource'] = {
141+
'attributes': resource_attributes,
142+
}
143+
return res
144+
145+
spans = [build_span(span) for span in self.exported_spans]
146+
return [
147+
span
148+
for span in spans
149+
if _include_pending_spans is True
150+
or (span.get('attributes', {}).get(ATTRIBUTES_SPAN_TYPE_KEY, 'span') != 'pending_span')
151+
]

logfire/testing.py

Lines changed: 12 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -2,166 +2,27 @@
22

33
from __future__ import annotations
44

5-
import os
65
import random
7-
import re
8-
import sys
9-
from collections.abc import Sequence
106
from dataclasses import dataclass
11-
from pathlib import Path
12-
from typing import Any, Mapping, cast
137

148
import pytest
15-
from opentelemetry import trace
169
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
17-
from opentelemetry.sdk.trace import Event, ReadableSpan
18-
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
10+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
1911
from opentelemetry.sdk.trace.id_generator import IdGenerator
20-
from opentelemetry.semconv.resource import ResourceAttributes
21-
from opentelemetry.semconv.trace import SpanAttributes
2212

2313
import logfire
2414

25-
from ._internal.constants import (
26-
ATTRIBUTES_SPAN_TYPE_KEY,
27-
ONE_SECOND_IN_NANOSECONDS,
28-
RESOURCE_ATTRIBUTES_PACKAGE_VERSIONS,
29-
)
30-
31-
32-
class TestExporter(SpanExporter):
33-
"""A SpanExporter that stores exported spans in a list for asserting in tests."""
34-
35-
# NOTE: Avoid test discovery by pytest.
36-
__test__ = False
37-
38-
def __init__(self) -> None:
39-
self.exported_spans: list[ReadableSpan] = []
40-
41-
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
42-
"""Exports a batch of telemetry data."""
43-
self.exported_spans.extend(spans)
44-
return SpanExportResult.SUCCESS
45-
46-
def clear(self) -> None:
47-
"""Clears the collected spans."""
48-
self.exported_spans = []
49-
50-
def exported_spans_as_dict(
51-
self,
52-
fixed_line_number: int | None = 123,
53-
strip_filepaths: bool = True,
54-
include_resources: bool = False,
55-
include_package_versions: bool = False,
56-
include_instrumentation_scope: bool = False,
57-
_include_pending_spans: bool = False,
58-
_strip_function_qualname: bool = True,
59-
) -> list[dict[str, Any]]:
60-
"""The exported spans as a list of dicts.
61-
62-
Args:
63-
fixed_line_number: The line number to use for all spans.
64-
strip_filepaths: Whether to strip the filepaths from the exported spans.
65-
include_resources: Whether to include the resource attributes in the exported spans.
66-
include_package_versions: Whether to include the package versions in the exported spans.
67-
include_instrumentation_scope: Whether to include the instrumentation scope in the exported spans.
68-
69-
Returns:
70-
A list of dicts representing the exported spans.
71-
"""
72-
73-
def process_attribute(name: str, value: Any) -> Any:
74-
if name == 'code.filepath' and strip_filepaths:
75-
try:
76-
return Path(value).name
77-
except ValueError: # pragma: no cover
78-
return value
79-
if name == 'code.lineno' and fixed_line_number is not None:
80-
return fixed_line_number
81-
if name == 'code.function':
82-
if sys.version_info >= (3, 11) and _strip_function_qualname:
83-
return value.split('.')[-1]
84-
if name == ResourceAttributes.PROCESS_PID:
85-
assert value == os.getpid()
86-
return 1234
87-
if name == ResourceAttributes.SERVICE_INSTANCE_ID:
88-
if re.match(r'^[0-9a-f]{32}$', value):
89-
return '0' * 32
90-
return value
91-
92-
def build_attributes(attributes: Mapping[str, Any] | None) -> dict[str, Any] | None:
93-
if attributes is None: # pragma: no branch
94-
return None # pragma: no cover
95-
attributes = {
96-
k: process_attribute(k, v)
97-
for k, v in attributes.items()
98-
if k != RESOURCE_ATTRIBUTES_PACKAGE_VERSIONS or include_package_versions
99-
}
100-
if 'telemetry.sdk.version' in attributes:
101-
attributes['telemetry.sdk.version'] = '0.0.0'
102-
return attributes
103-
104-
def build_event(event: Event) -> dict[str, Any]:
105-
res: dict[str, Any] = {
106-
'name': event.name,
107-
'timestamp': event.timestamp,
108-
}
109-
if event.attributes: # pragma: no branch
110-
res['attributes'] = attributes = dict(event.attributes)
111-
if SpanAttributes.EXCEPTION_STACKTRACE in attributes:
112-
last_line = next( # pragma: no branch
113-
line.strip()
114-
for line in reversed(
115-
cast(str, event.attributes[SpanAttributes.EXCEPTION_STACKTRACE]).split('\n')
116-
)
117-
if line.strip()
118-
)
119-
attributes[SpanAttributes.EXCEPTION_STACKTRACE] = last_line
120-
return res
121-
122-
def build_instrumentation_scope(span: ReadableSpan) -> dict[str, Any]:
123-
if include_instrumentation_scope:
124-
return {'instrumentation_scope': span.instrumentation_scope and span.instrumentation_scope.name}
125-
else:
126-
return {}
127-
128-
def build_span(span: ReadableSpan) -> dict[str, Any]:
129-
context = span.context or trace.INVALID_SPAN_CONTEXT
130-
res: dict[str, Any] = {
131-
'name': span.name,
132-
'context': {
133-
'trace_id': context.trace_id,
134-
'span_id': context.span_id,
135-
'is_remote': context.is_remote,
136-
},
137-
'parent': {
138-
'trace_id': span.parent.trace_id,
139-
'span_id': span.parent.span_id,
140-
'is_remote': span.parent.is_remote,
141-
}
142-
if span.parent
143-
else None,
144-
'start_time': span.start_time,
145-
'end_time': span.end_time,
146-
**build_instrumentation_scope(span),
147-
'attributes': build_attributes(span.attributes),
148-
}
149-
if span.events:
150-
res['events'] = [build_event(event) for event in span.events]
151-
if include_resources:
152-
resource_attributes = build_attributes(span.resource.attributes)
153-
res['resource'] = {
154-
'attributes': resource_attributes,
155-
}
156-
return res
157-
158-
spans = [build_span(span) for span in self.exported_spans]
159-
return [
160-
span
161-
for span in spans
162-
if _include_pending_spans is True
163-
or (span.get('attributes', {}).get(ATTRIBUTES_SPAN_TYPE_KEY, 'span') != 'pending_span')
164-
]
15+
from ._internal.constants import ONE_SECOND_IN_NANOSECONDS
16+
from ._internal.exporters.test import TestExporter
17+
18+
__all__ = [
19+
'capfire',
20+
'CaptureLogfire',
21+
'IncrementalIdGenerator',
22+
'SeededRandomIdGenerator',
23+
'TimeGenerator',
24+
'TestExporter',
25+
]
16526

16627

16728
@dataclass(repr=True)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logfire"
7-
version = "0.50.0"
7+
version = "0.50.1"
88
description = "The best Python observability tool! 🪵🔥"
99
authors = [
1010
{ name = "Pydantic Team", email = "[email protected]" },

0 commit comments

Comments
 (0)