Skip to content

Commit 24006d0

Browse files
committed
wip: fix more tests, squash and rebase
1 parent bd60475 commit 24006d0

File tree

7 files changed

+102
-109
lines changed

7 files changed

+102
-109
lines changed

dont-merge/fake-charm.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ def _on_setup_tracing(self, event: ops.SetupTracingEvent) -> None:
6969

7070
def _on_start(self, event: ops.StartEvent) -> None:
7171
"""Dummy docstring."""
72-
self.dummy_load(event, 0.0025)
72+
self.dummy_load(event)
7373
event.defer()
7474

7575
def _on_db_ready(self, event: DatabaseReadyEvent) -> None:
76-
self.dummy_load(event, 0.001)
76+
self.dummy_load(event)
7777

7878
def _on_collect_app_status(self, event: ops.CollectStatusEvent) -> None:
7979
"""Dummy docstring."""
@@ -86,7 +86,7 @@ def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None:
8686
event.add_status(ops.ActiveStatus('unit ready'))
8787

8888
@tracer.start_as_current_span('FakeCharm.dummy_load')
89-
def dummy_load(self, event: ops.EventBase, duration: float = 0.001) -> None:
89+
def dummy_load(self, event: ops.EventBase, duration: float = 0.0001) -> None:
9090
"""Dummy docstring."""
9191
print(event)
9292
time.sleep(duration)

ops/_tracing/buffer.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,10 @@ def _set_db_schema(self):
103103
id INTEGER PRIMARY KEY,
104104
-- observed events are more important
105105
priority INTEGER NOT NULL,
106-
-- Protobuf-formatted tracing data
107-
data BLOB NOT NULL
106+
-- a chunk of tracing data
107+
data BLOB NOT NULL,
108+
-- MIME type for that chunk
109+
mime TEXT NOT NULL
108110
)
109111
"""
110112
)
@@ -155,7 +157,7 @@ def mark_observed(self):
155157
self.ids.clear()
156158

157159
@retry
158-
def pump(self, chunk: bytes | None = None) -> tuple[int, bytes] | None:
160+
def pump(self, data: tuple[bytes, str] | None = None) -> tuple[int, bytes, str] | None:
159161
"""Pump the buffer queue.
160162
161163
Accepts an optional new data chunk.
@@ -167,12 +169,13 @@ def pump(self, chunk: bytes | None = None) -> tuple[int, bytes] | None:
167169
# - or a read transaction later upgraded to write (check space, then delete some)
168170
# currently I've made `self.tx()` return a write transaction always
169171
# which is safer, but may incur a filesystem modification cost.
172+
chunk, mime = data if data else (None, None)
173+
chunklen = 0 if chunk is None else (len(chunk) + 4095) // 4096 * 4096
170174
collected_size = 0
171-
chunklen = 0
175+
172176
with self.tx(readonly=not chunk) as conn:
173177
if chunk:
174178
# Ensure that there's enough space in the buffer
175-
chunklen = (len(chunk) + 4095) // 4096 * 4096
176179

177180
# TODO: expose `stored` in metrics, one day
178181
if self.stored is None:
@@ -209,10 +212,10 @@ def pump(self, chunk: bytes | None = None) -> tuple[int, bytes] | None:
209212
priority = OBSERVED_PRIORITY if self.observed else DEFAULT_PRIORITY
210213
cursor = conn.execute(
211214
"""
212-
INSERT INTO tracing (priority, data)
213-
VALUES (?, ?)
215+
INSERT INTO tracing (priority, data, mime)
216+
VALUES (?, ?, ?)
214217
""",
215-
(priority, chunk),
218+
(priority, chunk, mime),
216219
)
217220

218221
assert cursor.lastrowid is not None
@@ -222,7 +225,7 @@ def pump(self, chunk: bytes | None = None) -> tuple[int, bytes] | None:
222225
# Return oldest important data
223226
rv = conn.execute(
224227
"""
225-
SELECT id, data
228+
SELECT id, data, mime
226229
FROM tracing
227230
ORDER BY priority DESC, id ASC
228231
LIMIT 1

ops/_tracing/export.py

+67-82
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
import contextlib
1717
import logging
1818
import os
19+
import ssl
1920
import threading
2021
import time
21-
from typing import Any, Sequence
22+
import urllib.request
23+
from typing import Sequence
2224

23-
from opentelemetry.exporter.otlp.proto.common._internal import trace_encoder
24-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
25+
# FIXME: single-file Python package can't be marked as py.typed
26+
import otlp_json # type: ignore
2527
from opentelemetry.instrumentation.urllib import URLLibInstrumentor
2628
from opentelemetry.sdk.resources import Resource
2729
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
@@ -33,6 +35,9 @@
3335
import ops.jujucontext
3436
import ops.log
3537

38+
# FIXME otlp_json is typed...
39+
# https://github.com/python/typing/issues/1333
40+
3641
# Trace `urllib` usage when talking to Pebble
3742
URLLibInstrumentor().instrument()
3843

@@ -56,40 +61,14 @@
5661
logger.addHandler(logging.StreamHandler())
5762

5863

59-
def proto_to_json(data: Any) -> str:
60-
"""FIXME: move to own module and reimplement"""
61-
# xxx
62-
import base64
63-
import json
64-
from google.protobuf.json_format import MessageToDict
65-
dic = MessageToDict(data)
66-
67-
for rs in dic["resourceSpans"]:
68-
for ss in rs["scopeSpans"]:
69-
for sp in ss["spans"]:
70-
for k in "parentSpanId spanId traceId".split():
71-
if k in sp:
72-
sp[k] = base64.b64decode(sp[k]).hex()
73-
sp["kind"] = {
74-
"SPAN_KIND_UNSPECIFIED": 0,
75-
"SPAN_KIND_INTERNAL": 1,
76-
"SPAN_KIND_SERVER": 2,
77-
"SPAN_KIND_CLIENT": 3,
78-
"SPAN_KIND_PRODUCER": 4,
79-
"SPAN_KIND_CONSUMER": 5,
80-
}[sp["kind"]]
81-
82-
return json.dumps(dic)
83-
84-
85-
8664
class ProxySpanExporter(SpanExporter):
87-
real_exporter: OTLPSpanExporter | None = None
8865
settings: tuple[str | None, str | None] = (None, None)
66+
cache: dict[str | None, ssl.SSLContext]
8967

9068
def __init__(self, buffer_path: str):
9169
self.buffer = ops._tracing.buffer.Buffer(buffer_path)
9270
self.lock = threading.Lock()
71+
self.cache = {}
9372

9473
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
9574
"""Export a batch of telemetry data.
@@ -110,15 +89,9 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
11089
assert spans # the BatchSpanProcessor won't call us if there's no data
11190
# TODO: this will change in the JSON experiment
11291
# __import__("pdb").set_trace()
113-
data: bytes = proto_to_json(trace_encoder.encode_spans(spans)).encode("utf-8")
11492
# FIXME can't use stock exporter, must DIY
115-
import requests
116-
r = requests.post("http://localhost:4318/v1/traces",
117-
data=data,
118-
headers={"Content-Type": "application/json"})
119-
r.raise_for_status()
12093

121-
rv = self.buffer.pump(data)
94+
rv = self.buffer.pump((otlp_json.encode_spans(spans), otlp_json.CONTENT_TYPE))
12295
assert rv
12396
self.do_export(*rv)
12497

@@ -134,18 +107,65 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
134107
logger.exception('export')
135108
raise
136109

137-
def do_export(self, buffered_id: int, data: bytes) -> None:
110+
def ssl_context(self, ca: str | None) -> ssl.SSLContext:
111+
if context := self.cache.get(ca):
112+
return context
113+
context = self._ssl_context(ca)
114+
self.cache.clear()
115+
self.cache[ca] = context
116+
return context
117+
118+
def _ssl_context(self, ca: str | None) -> ssl.SSLContext:
119+
# FIXME: What should our protocol range be?
120+
# this means TLS {v1, v1.1, v1.2}
121+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
122+
# FIXME: we should probably allow ca=None
123+
# and then we'd pick up system or certifi certs?
124+
assert ca
125+
context.load_verify_locations(cadata=ca)
126+
# FIXME: what's recommended?
127+
# context.set_ciphers(...)
128+
# FIXME: we need to set NPN if we're setting ALPN?
129+
# Does this work the same way across Py 3.8~3.13?
130+
context.set_npn_protocols('http/1.1')
131+
context.set_alpn_protocols('http/1.1')
132+
# TODO: check that we don't need these:
133+
# .set_sni_callback
134+
return context
135+
136+
def do_export(self, buffered_id: int, data: bytes, mime: str) -> None:
138137
"""Export buffered data and remove it from the buffer on success."""
139-
return
140-
# FIXME: this will change in the JSON experiment
141-
exporter = self.real_exporter
142-
if exporter and exporter._export(data).ok:
143-
self.buffer.remove(buffered_id)
138+
print(self.settings, len(data), mime)
139+
url, ca = self.settings
140+
if not url:
141+
return
142+
143+
# FIXME cache
144+
145+
# FIXME: is this custom code worth it?
146+
# or would it be easier and safer to use `requests`?
147+
assert url.startswith(('http://', 'https://'))
148+
context = self.ssl_context(ca) if url.startswith('https://') else None
149+
150+
with urllib.request.urlopen( # noqa: S310
151+
urllib.request.Request( # noqa: S310
152+
url,
153+
data=data,
154+
headers={'Content-Type': mime},
155+
method='POST',
156+
),
157+
context=context,
158+
) as rv:
159+
# from typing_extensions import reveal_type
160+
# reveal_type(rv.status) # Any
161+
#
162+
# FIXME: .status requires Python 3.9+, WAT?
163+
if rv.status < 300:
164+
self.buffer.remove(buffered_id)
144165

145166
def shutdown(self) -> None:
146167
"""Shut down the exporter."""
147-
if exporter := self.real_exporter:
148-
exporter.shutdown()
168+
pass
149169

150170
def force_flush(self, timeout_millis: int = 30000) -> bool:
151171
"""No-op, as the real exporter doesn't buffer."""
@@ -243,42 +263,7 @@ def set_tracing_destination(
243263
raise ValueError(f'{ca=} must be an absolute path')
244264

245265
assert _exporter, 'tracing has not been set up'
246-
with _exporter.lock:
247-
if (url, ca) != _exporter.settings:
248-
if url:
249-
# real exporter, hardcoded for now
250-
real_exporter = OTLPSpanExporter(url, timeout=EXPORTER_TIMEOUT)
251-
# FIXME: shouldn't be hardcoded...
252-
# FIXME API design: if it OK to force the protocol and endpoint
253-
# switch onto the charmers, our users?
254-
#
255-
# OTLP protobuf URL is host:4318/v1/traces
256-
# Zipkin v2 JSON URL is host:9411/api/v2/spans
257-
#
258-
# FIXME: on the other hand, Jaeger 2 should accept OTLP JSON too
259-
# https://www.jaegertracing.io/docs/2.3/apis/#opentelemetry-protocol
260-
#
261-
# The real question is what COS and COS-lite accept.
262-
#
263-
# json_url = 'http://localhost:9411/api/v2/spans'
264-
# TODO: session=<custom session that groks ca= better>
265-
# zipkin_exporter = ZipkinExporter(
266-
# endpoint=json_url, timeout=EXPORTER_TIMEOUT
267-
# )
268-
# This is actually the max delay value in the sequence 1, 2, ..., MAX
269-
# Set to 1 to disable sending live data (buffered data is still eventually sent)
270-
# Set to 2 (or more) to enable sending live data (after buffered)
271-
#
272-
# _MAX_RETRY_TIMEOUT = 2 with timeout=1 means:
273-
# - 1 attempt to send live, 1s sleep in the worst case
274-
# _MAX_RETRY_TIMEOUT = 3 or 4 with timeout=1 means:
275-
# - 1st attempt, 1s sleep, 2nd attempt, 1s sleep in the worst case
276-
real_exporter._MAX_RETRY_TIMEOUT = 2 # pyright: ignore[reportAttributeAccessIssue]
277-
else:
278-
real_exporter = None
279-
280-
_exporter.real_exporter = real_exporter
281-
_exporter.settings = (url, ca)
266+
_exporter.settings = (url, ca)
282267

283268
_exporter.buffer.mark_observed()
284269

pyproject.toml

+3-2
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ testing = [
4343
"ops-scenario>=7.0.5,<8",
4444
]
4545
tracing = [
46-
"opentelemetry-exporter-otlp-proto-http~=1.30",
47-
"opentelemetry-instrumentation-urllib~=0.51b0", # must match the above
46+
"otlp-json>=0.9.1",
47+
"opentelemetry-instrumentation-urllib~=0.51b0", # decide if we want this
48+
"opentelemetry-sdk~=1.30", # version should match -api
4849
]
4950
# Empty for now, because Harness is bundled with the base install, but allow
5051
# specifying the extra to ease transition later.

testing/tests/test_e2e/test_actions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_action_event(mycharm, baz_value):
3737
state = ctx.run(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State())
3838

3939
assert isinstance(state, State)
40-
evt = ctx.emitted_events[0]
40+
_setup, evt = ctx.emitted_events
4141
assert evt.params["bar"] == 10
4242
assert evt.params["baz"] is baz_value
4343

testing/tests/test_e2e/test_deferred.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
CharmBase,
44
CollectStatusEvent,
55
RelationChangedEvent,
6+
SetupTracingEvent,
67
StartEvent,
78
UpdateStatusEvent,
89
WorkloadEvent,
@@ -30,6 +31,10 @@ class MyCharm(CharmBase):
3031
def __init__(self, framework: Framework):
3132
super().__init__(framework)
3233
for evt in self.on.events().values():
34+
# Life cycle events cannot be deferred
35+
if evt.event_type in (SetupTracingEvent, CollectStatusEvent):
36+
continue
37+
3338
self.framework.observe(evt, self._on_event)
3439

3540
def _on_event(self, event):
@@ -64,8 +69,8 @@ def test_deferred_evt_emitted(mycharm):
6469
assert out.deferred[1].name == "update_status"
6570

6671
# we saw start and update-status.
67-
assert len(mycharm.captured) == 3
68-
upstat, start, _ = mycharm.captured
72+
assert len(mycharm.captured) == 2
73+
upstat, start = mycharm.captured
6974
assert isinstance(upstat, UpdateStatusEvent)
7075
assert isinstance(start, StartEvent)
7176

@@ -95,8 +100,8 @@ def test_deferred_relation_event(mycharm):
95100
assert out.deferred[1].name == "start"
96101

97102
# we saw start and relation-changed.
98-
assert len(mycharm.captured) == 3
99-
upstat, start, _ = mycharm.captured
103+
assert len(mycharm.captured) == 2
104+
upstat, start = mycharm.captured
100105
assert isinstance(upstat, RelationChangedEvent)
101106
assert isinstance(start, StartEvent)
102107

@@ -131,11 +136,10 @@ def test_deferred_relation_event_from_relation(mycharm):
131136
assert out.deferred[1].name == "start"
132137

133138
# we saw start and foo_relation_changed.
134-
assert len(mycharm.captured) == 3
135-
upstat, start, collect_status = mycharm.captured
139+
assert len(mycharm.captured) == 2
140+
upstat, start = mycharm.captured
136141
assert isinstance(upstat, RelationChangedEvent)
137142
assert isinstance(start, StartEvent)
138-
assert isinstance(collect_status, CollectStatusEvent)
139143

140144

141145
def test_deferred_workload_event(mycharm):
@@ -164,10 +168,9 @@ def test_deferred_workload_event(mycharm):
164168

165169
# we saw start and foo_pebble_ready.
166170
assert len(mycharm.captured) == 3
167-
upstat, start, collect_status = mycharm.captured
171+
upstat, start = mycharm.captured
168172
assert isinstance(upstat, WorkloadEvent)
169173
assert isinstance(start, StartEvent)
170-
assert isinstance(collect_status, CollectStatusEvent)
171174

172175

173176
def test_defer_reemit_lifecycle_event(mycharm):

0 commit comments

Comments
 (0)