owl24-py 0.1.0__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.
@@ -0,0 +1,46 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+ .env*
26
+ # .env.example files are committed templates, not secrets - without this
27
+ # negation the blanket .env* rule above hides them from every `git status`/
28
+ # `git add`, so they silently never get committed (verified: none of the
29
+ # .env.example files across clientservers/, py-server/, or java-server/
30
+ # were actually tracked, despite being referenced as the setup reference
31
+ # throughout).
32
+ !.env.example
33
+ !**/.env.example
34
+ BackendCollector/data*
35
+ BackendCollector/queries*
36
+
37
+ # Java / Maven build output
38
+ target/
39
+ *.class
40
+
41
+ # Python build/cache output
42
+ __pycache__/
43
+ *.pyc
44
+ *.egg-info/
45
+ .venv/
46
+ venv/
owl24_py-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Madnands5
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: owl24-py
3
+ Version: 0.1.0
4
+ Summary: Custom owl24 OpenTelemetry wrapper with data masking
5
+ Project-URL: Homepage, https://owl24.dev
6
+ Project-URL: Repository, https://github.com/Madnands5/owl24
7
+ Author: Madnands5
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: apm,logging,monitoring,observability,opentelemetry,owl24,tracing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Python: >=3.8
16
+ Requires-Dist: opentelemetry-api<2.0.0,>=1.39.0
17
+ Requires-Dist: opentelemetry-exporter-otlp<2.0.0,>=1.39.0
18
+ Requires-Dist: opentelemetry-instrumentation-system-metrics>=0.65b0
19
+ Requires-Dist: opentelemetry-sdk<2.0.0,>=1.39.0
20
+ Provides-Extra: django
21
+ Requires-Dist: opentelemetry-instrumentation-django>=0.65b0; extra == 'django'
22
+ Provides-Extra: fastapi
23
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.65b0; extra == 'fastapi'
24
+ Provides-Extra: flask
25
+ Requires-Dist: opentelemetry-instrumentation-flask>=0.65b0; extra == 'flask'
26
+ Provides-Extra: requests
27
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.65b0; extra == 'requests'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # owl24-py
31
+
32
+ Python SDK for [owl24](https://owl24.dev) — one line of code to send logs, traces, and metrics to your owl24 dashboard, built on OpenTelemetry.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install owl24-py
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import os
44
+ from owl24_py import Owl24
45
+
46
+ Owl24.init(os.getenv("OWL24_API_KEY"), "my-service-name")
47
+ ```
48
+
49
+ That's it — `Owl24.init(...)` wires up an OpenTelemetry tracer/meter/logger provider pointed at your owl24 ingest endpoint, and patches Python's `logging` module so `logging.info(...)`, `logging.error(...)`, etc. are automatically sent to your dashboard alongside the current trace/span ID.
50
+
51
+ ### Options
52
+
53
+ ```python
54
+ Owl24.init(
55
+ api_key=None, # or set OWL24_API_KEY / OBSERVE_API_KEY env var
56
+ service_name="dice-server",
57
+ export_interval_millis=3000,
58
+ disable_console_bridge=False, # set True to stop auto-forwarding logging.* calls
59
+ disable_crash_capture=False, # set True to disable uncaught-exception capture
60
+ export_timeout_millis=5000,
61
+ disable_auto_instrumentation=False, # set True to skip Flask/Django/FastAPI/requests auto-tracing
62
+ )
63
+ ```
64
+
65
+ Telemetry is always sent to `https://ingest.owl24.dev` — owl24's ingest endpoint isn't configurable, since it's a fixed part of the hosted service (only the API key is per-customer).
66
+
67
+ ## What it does
68
+
69
+ - **Logs**: patches the root `logging` logger — every `logging.*` call is forwarded as a structured log, tagged with the active trace/span ID if one exists.
70
+ - **Traces**: sets up an OpenTelemetry `TracerProvider` exporting via OTLP/HTTP. Spans you create manually, or automatically via Flask/Django/FastAPI/`requests` (see below), are masked and exported.
71
+ - **Host metrics**: CPU, memory, and network metrics are collected and exported automatically via `opentelemetry-instrumentation-system-metrics` — no setup required.
72
+ - **Crash capture**: installs a `sys.excepthook` and `threading.excepthook` so uncaught exceptions (main thread and background threads) are captured as FATAL-severity log events and flushed before the process exits.
73
+ - **PII masking**: emails, credit-card-shaped numbers, phone numbers, and bearer tokens are scrubbed from span attributes and log bodies before they ever leave your process.
74
+
75
+ ## Automatic HTTP tracing
76
+
77
+ Install the extra for whichever framework/library you use, and `Owl24.init()` will automatically create spans for incoming requests (Flask/Django/FastAPI) or outgoing calls (`requests`) — no other code changes needed:
78
+
79
+ ```bash
80
+ pip install "owl24-py[flask]" # Flask
81
+ pip install "owl24-py[django]" # Django
82
+ pip install "owl24-py[fastapi]" # FastAPI
83
+ pip install "owl24-py[requests]" # outgoing requests via the `requests` library
84
+ ```
85
+
86
+ None of these are installed by default — `pip install owl24-py` alone still gives you logs, host metrics, crash capture, and manually-created spans, with zero extra framework dependencies pulled in.
87
+
88
+ ### Flask and FastAPI: call `Owl24.init()` before importing the framework
89
+
90
+ For Flask and FastAPI specifically (not Django - see below), `Owl24.init()` must run **before your own code does `from flask import Flask` or `from fastapi import FastAPI`**:
91
+
92
+ ```python
93
+ from owl24_py import Owl24
94
+ Owl24.init(api_key, "my-service")
95
+
96
+ from flask import Flask # import AFTER init() - this is the part that matters
97
+ app = Flask(__name__)
98
+ ```
99
+
100
+ Getting this backwards doesn't raise an error or a warning - it just silently produces zero traces, ever, for that service. Why: Flask/FastAPI's instrumentation works by reassigning the framework's own `Flask`/`FastAPI` class in its module (e.g. `flask.Flask = _InstrumentedFlask`) - if your code already did `from flask import Flask` and bound that name to the original class before `Owl24.init()` runs, that name keeps pointing at the unpatched original forever; reassigning `flask.Flask` afterward can't reach back and fix an already-bound reference. (Using `import flask` and calling `flask.Flask(...)` instead of `from flask import Flask` sidesteps this entirely, since that always resolves the class fresh - but `Owl24.init()` first is the simpler rule to just always follow.)
101
+
102
+ **Django doesn't have this restriction** - its instrumentation works by inserting into Django's `settings.MIDDLEWARE`, which Django resolves lazily when it actually starts handling requests, not by reassigning a class. Import order doesn't matter for Django.
103
+
104
+ ## License
105
+
106
+ MIT — see [LICENSE](https://github.com/Madnands5/owl24/blob/main/packages/owl24-py/LICENSE).
@@ -0,0 +1,77 @@
1
+ # owl24-py
2
+
3
+ Python SDK for [owl24](https://owl24.dev) — one line of code to send logs, traces, and metrics to your owl24 dashboard, built on OpenTelemetry.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install owl24-py
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import os
15
+ from owl24_py import Owl24
16
+
17
+ Owl24.init(os.getenv("OWL24_API_KEY"), "my-service-name")
18
+ ```
19
+
20
+ That's it — `Owl24.init(...)` wires up an OpenTelemetry tracer/meter/logger provider pointed at your owl24 ingest endpoint, and patches Python's `logging` module so `logging.info(...)`, `logging.error(...)`, etc. are automatically sent to your dashboard alongside the current trace/span ID.
21
+
22
+ ### Options
23
+
24
+ ```python
25
+ Owl24.init(
26
+ api_key=None, # or set OWL24_API_KEY / OBSERVE_API_KEY env var
27
+ service_name="dice-server",
28
+ export_interval_millis=3000,
29
+ disable_console_bridge=False, # set True to stop auto-forwarding logging.* calls
30
+ disable_crash_capture=False, # set True to disable uncaught-exception capture
31
+ export_timeout_millis=5000,
32
+ disable_auto_instrumentation=False, # set True to skip Flask/Django/FastAPI/requests auto-tracing
33
+ )
34
+ ```
35
+
36
+ Telemetry is always sent to `https://ingest.owl24.dev` — owl24's ingest endpoint isn't configurable, since it's a fixed part of the hosted service (only the API key is per-customer).
37
+
38
+ ## What it does
39
+
40
+ - **Logs**: patches the root `logging` logger — every `logging.*` call is forwarded as a structured log, tagged with the active trace/span ID if one exists.
41
+ - **Traces**: sets up an OpenTelemetry `TracerProvider` exporting via OTLP/HTTP. Spans you create manually, or automatically via Flask/Django/FastAPI/`requests` (see below), are masked and exported.
42
+ - **Host metrics**: CPU, memory, and network metrics are collected and exported automatically via `opentelemetry-instrumentation-system-metrics` — no setup required.
43
+ - **Crash capture**: installs a `sys.excepthook` and `threading.excepthook` so uncaught exceptions (main thread and background threads) are captured as FATAL-severity log events and flushed before the process exits.
44
+ - **PII masking**: emails, credit-card-shaped numbers, phone numbers, and bearer tokens are scrubbed from span attributes and log bodies before they ever leave your process.
45
+
46
+ ## Automatic HTTP tracing
47
+
48
+ Install the extra for whichever framework/library you use, and `Owl24.init()` will automatically create spans for incoming requests (Flask/Django/FastAPI) or outgoing calls (`requests`) — no other code changes needed:
49
+
50
+ ```bash
51
+ pip install "owl24-py[flask]" # Flask
52
+ pip install "owl24-py[django]" # Django
53
+ pip install "owl24-py[fastapi]" # FastAPI
54
+ pip install "owl24-py[requests]" # outgoing requests via the `requests` library
55
+ ```
56
+
57
+ None of these are installed by default — `pip install owl24-py` alone still gives you logs, host metrics, crash capture, and manually-created spans, with zero extra framework dependencies pulled in.
58
+
59
+ ### Flask and FastAPI: call `Owl24.init()` before importing the framework
60
+
61
+ For Flask and FastAPI specifically (not Django - see below), `Owl24.init()` must run **before your own code does `from flask import Flask` or `from fastapi import FastAPI`**:
62
+
63
+ ```python
64
+ from owl24_py import Owl24
65
+ Owl24.init(api_key, "my-service")
66
+
67
+ from flask import Flask # import AFTER init() - this is the part that matters
68
+ app = Flask(__name__)
69
+ ```
70
+
71
+ Getting this backwards doesn't raise an error or a warning - it just silently produces zero traces, ever, for that service. Why: Flask/FastAPI's instrumentation works by reassigning the framework's own `Flask`/`FastAPI` class in its module (e.g. `flask.Flask = _InstrumentedFlask`) - if your code already did `from flask import Flask` and bound that name to the original class before `Owl24.init()` runs, that name keeps pointing at the unpatched original forever; reassigning `flask.Flask` afterward can't reach back and fix an already-bound reference. (Using `import flask` and calling `flask.Flask(...)` instead of `from flask import Flask` sidesteps this entirely, since that always resolves the class fresh - but `Owl24.init()` first is the simpler rule to just always follow.)
72
+
73
+ **Django doesn't have this restriction** - its instrumentation works by inserting into Django's `settings.MIDDLEWARE`, which Django resolves lazily when it actually starts handling requests, not by reassigning a class. Import order doesn't matter for Django.
74
+
75
+ ## License
76
+
77
+ MIT — see [LICENSE](https://github.com/Madnands5/owl24/blob/main/packages/owl24-py/LICENSE).
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "owl24-py"
7
+ version = "0.1.0"
8
+ description = "Custom owl24 OpenTelemetry wrapper with data masking"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Madnands5" }
14
+ ]
15
+ keywords = ["observability", "opentelemetry", "apm", "tracing", "logging", "monitoring", "owl24"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Monitoring",
21
+ ]
22
+ dependencies = [
23
+ # Floor raised to 1.39 (not the original 1.27): that's the release where
24
+ # opentelemetry.sdk._logs.LogRecord was removed in favor of passing
25
+ # kwargs directly to Logger.emit(), which is what telemetry.py now does
26
+ # - older versions don't support that emit() signature.
27
+ "opentelemetry-api>=1.39.0,<2.0.0",
28
+ "opentelemetry-sdk>=1.39.0,<2.0.0",
29
+ "opentelemetry-exporter-otlp>=1.39.0,<2.0.0",
30
+ # Host metrics (CPU/memory/network) - the Python equivalent of
31
+ # owl24-js's @opentelemetry/host-metrics and owl24-java's
32
+ # runtime-telemetry-java8. A hard dependency (unlike the framework
33
+ # instrumentations below) since it's universal - no per-framework
34
+ # variance, so there's no reason to make it opt-in.
35
+ "opentelemetry-instrumentation-system-metrics>=0.65b0"
36
+ ]
37
+
38
+ # Optional auto-tracing for common frameworks/libraries - not hard
39
+ # dependencies, since forcing e.g. Flask onto a Django or plain-script user
40
+ # would be dead weight. Each one is also try/except-guarded at import time
41
+ # in telemetry.py, so init() still works fine with none of these installed
42
+ # (falls back to logs-only + manual spans, same as today) - installing the
43
+ # extra is what turns on automatic HTTP span creation for that library.
44
+ [project.optional-dependencies]
45
+ flask = ["opentelemetry-instrumentation-flask>=0.65b0"]
46
+ django = ["opentelemetry-instrumentation-django>=0.65b0"]
47
+ fastapi = ["opentelemetry-instrumentation-fastapi>=0.65b0"]
48
+ requests = ["opentelemetry-instrumentation-requests>=0.65b0"]
49
+
50
+ [project.urls]
51
+ Homepage = "https://owl24.dev"
52
+ Repository = "https://github.com/Madnands5/owl24"
@@ -0,0 +1 @@
1
+ from .telemetry import Owl24
@@ -0,0 +1,397 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import json
5
+ import logging
6
+ import signal
7
+ import threading
8
+ import traceback
9
+ from typing import Any
10
+ from types import MappingProxyType
11
+
12
+ # This module's own print statements use emoji (see below) - on Windows,
13
+ # the console's default codepage (cp1252) can't encode them, which throws
14
+ # a UnicodeEncodeError right on the *success* print at the end of init()'s
15
+ # try-block - meaning init() silently reported "Init failed" even when
16
+ # everything actually succeeded. Forcing UTF-8 here (guarded: reconfigure()
17
+ # doesn't exist on every stream type, and this must never itself crash the
18
+ # import) fixes that instead of stripping the emoji.
19
+ for _stream in (sys.stdout, sys.stderr):
20
+ try:
21
+ _stream.reconfigure(encoding="utf-8", errors="replace")
22
+ except Exception:
23
+ pass
24
+
25
+ from opentelemetry import trace, metrics
26
+ from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
27
+ from opentelemetry.sdk.trace import TracerProvider
28
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
29
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
30
+ from opentelemetry.sdk.metrics import MeterProvider
31
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
32
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
33
+ from opentelemetry._logs import SeverityNumber, set_logger_provider
34
+ from opentelemetry.sdk._logs import LoggerProvider
35
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
36
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
37
+ from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
38
+
39
+ # Best-effort auto-tracing for whichever of these the host app already has
40
+ # installed - mirrors owl24-js's getNodeAutoInstrumentations() "instrument
41
+ # whatever's present" behavior, without making any of them a hard dependency
42
+ # of this package (see the `[project.optional-dependencies]` extras in
43
+ # pyproject.toml). Each import is independently guarded: a host app with
44
+ # none of these installed still gets logs + manual spans + host metrics,
45
+ # exactly as before.
46
+ _AUTO_INSTRUMENTORS = []
47
+ try:
48
+ from opentelemetry.instrumentation.flask import FlaskInstrumentor
49
+ _AUTO_INSTRUMENTORS.append(("flask", FlaskInstrumentor))
50
+ except ImportError:
51
+ pass
52
+ try:
53
+ from opentelemetry.instrumentation.django import DjangoInstrumentor
54
+ _AUTO_INSTRUMENTORS.append(("django", DjangoInstrumentor))
55
+ except ImportError:
56
+ pass
57
+ try:
58
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
59
+ _AUTO_INSTRUMENTORS.append(("fastapi", FastAPIInstrumentor))
60
+ except ImportError:
61
+ pass
62
+ try:
63
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
64
+ _AUTO_INSTRUMENTORS.append(("requests", RequestsInstrumentor))
65
+ except ImportError:
66
+ pass
67
+
68
+ # Maps Python's stdlib level names to the OTLP SeverityNumber enum - the log
69
+ # bridge previously stored logging._levelToName's raw string here (wrong
70
+ # type for this field, and a private stdlib internal besides).
71
+ _PYTHON_TO_OTEL_SEVERITY = {
72
+ "DEBUG": SeverityNumber.DEBUG,
73
+ "INFO": SeverityNumber.INFO,
74
+ "WARNING": SeverityNumber.WARN,
75
+ "ERROR": SeverityNumber.ERROR,
76
+ "CRITICAL": SeverityNumber.FATAL,
77
+ }
78
+
79
+
80
+ MASK_PATTERNS = {
81
+ "email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
82
+ "creditCard": re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
83
+ "phone": re.compile(r"(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"),
84
+ "bearerToken": re.compile(r"Bearer\s+[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*")
85
+ }
86
+
87
+ def mask_sensitive_data(text: str) -> str:
88
+ if not isinstance(text, str):
89
+ return text
90
+ text = MASK_PATTERNS["email"].sub("[EMAIL_MASKED]", text)
91
+ text = MASK_PATTERNS["creditCard"].sub("[CARD_MASKED]", text)
92
+ text = MASK_PATTERNS["phone"].sub("[PHONE_MASKED]", text)
93
+ text = MASK_PATTERNS["bearerToken"].sub("[TOKEN_MASKED]", text)
94
+ return text
95
+
96
+ def safe_serialize(obj: Any) -> str:
97
+ try:
98
+ return json.dumps(obj, default=lambda o: "[Unserializable Object]")
99
+ except ValueError:
100
+ return "[Circular]"
101
+ except Exception:
102
+ return "[Unserializable Object]"
103
+
104
+ class _MaskedReadableSpan:
105
+ """Duck-typed proxy around a real ReadableSpan: delegates every
106
+ attribute except `.attributes` to the wrapped span unchanged, and
107
+ returns a masked copy for `.attributes`. The OTLP exporter's encoder
108
+ only ever reads `sdk_span.attributes` (type-hinted as ReadableSpan, but
109
+ never isinstance-checked), so this duck typing is enough for it to
110
+ treat this exactly like a real span.
111
+ """
112
+
113
+ def __init__(self, wrapped, masked_attributes):
114
+ self._wrapped = wrapped
115
+ self._masked_attributes = masked_attributes
116
+
117
+ def __getattr__(self, name):
118
+ return getattr(self._wrapped, name)
119
+
120
+ @property
121
+ def attributes(self):
122
+ return self._masked_attributes
123
+
124
+
125
+ class MaskingSpanExporter(SpanExporter):
126
+ """Wraps a SpanExporter and masks string span attributes before they
127
+ reach the real exporter - closes the masking gap for auto-instrumented
128
+ spans (http.url query strings, db.statement literals, etc.), which
129
+ never went through mask_sensitive_data before (only the logging bridge
130
+ above did).
131
+
132
+ This masks into a *copy* rather than mutating the original span's
133
+ attributes in place (the approach used by owl24-js's equivalent
134
+ MaskingSpanProcessor, and originally attempted here too) because the
135
+ current OTel Python SDK makes a span's attributes hard-immutable the
136
+ moment end() is called (`self._attributes._immutable = True`, set
137
+ before any processor's on_end() even runs) - verified live: mutating
138
+ either the public `.attributes` property (a fresh MappingProxyType each
139
+ time) or the private `._attributes` BoundedAttributes both raise
140
+ TypeError, unconditionally, for every span. There's no supported way to
141
+ mutate a span after it ends in this SDK version, so masking has to
142
+ happen at the exporter boundary instead, the same place owl24-java's
143
+ equivalent gap gets closed (its ReadOnlySpan is sealed against external
144
+ implementations too, for a different underlying reason).
145
+ """
146
+
147
+ def __init__(self, wrapped):
148
+ self._wrapped = wrapped
149
+
150
+ def export(self, spans):
151
+ masked_spans = []
152
+ for span in spans:
153
+ try:
154
+ original_attrs = dict(span.attributes or {})
155
+ masked_attrs = {
156
+ key: (mask_sensitive_data(value) if isinstance(value, str) else value)
157
+ for key, value in original_attrs.items()
158
+ }
159
+ masked_spans.append(_MaskedReadableSpan(span, MappingProxyType(masked_attrs)))
160
+ except Exception as e:
161
+ print(f"❌ Owl24 Masking Error: {e}", file=sys.stderr)
162
+ masked_spans.append(span)
163
+ return self._wrapped.export(masked_spans)
164
+
165
+ def shutdown(self):
166
+ return self._wrapped.shutdown()
167
+
168
+ def force_flush(self, timeout_millis=30000):
169
+ force_flush_fn = getattr(self._wrapped, "force_flush", None)
170
+ return force_flush_fn(timeout_millis) if force_flush_fn else True
171
+
172
+
173
+ # Logger name prefixes excluded from the console bridge - the exporters
174
+ # themselves (and the HTTP libraries they use) log through this same root
175
+ # logger when a request fails/retries. Without this exclusion, a failed
176
+ # export logs a warning through `logging`, the bridge captures that warning
177
+ # and tries to export IT too, which can itself fail and log another
178
+ # warning, recursively - verified live: this hung a real crash-capture
179
+ # flush indefinitely (no bound at all, worse than just being slow) once the
180
+ # ingest endpoint was unreachable, which is exactly the moment a crash
181
+ # handler needs to be reliable.
182
+ _EXCLUDED_LOGGER_PREFIXES = ("opentelemetry", "urllib3", "requests")
183
+
184
+
185
+ class MaskingAndOtelHandler(logging.Handler):
186
+ def __init__(self, otel_logger):
187
+ super().__init__()
188
+ self.otel_logger = otel_logger
189
+
190
+ def emit(self, record):
191
+ if record.name.startswith(_EXCLUDED_LOGGER_PREFIXES):
192
+ return
193
+ try:
194
+ msg = record.getMessage()
195
+ body = safe_serialize(msg) if isinstance(msg, (dict, list)) else str(msg)
196
+ masked_body = mask_sensitive_data(body)
197
+
198
+ current_span = trace.get_current_span()
199
+ span_context = current_span.get_span_context() if current_span else None
200
+
201
+ trace_id = format(span_context.trace_id, '032x') if span_context and span_context.trace_id else None
202
+ span_id = format(span_context.span_id, '016x') if span_context and span_context.span_id else None
203
+
204
+ level_name = logging.getLevelName(record.levelno)
205
+ # Passed as kwargs directly (not a constructed LogRecord - that
206
+ # class was removed from opentelemetry-sdk in 1.39, which
207
+ # `Logger.emit()` builds internally from these same kwargs).
208
+ self.otel_logger.emit(
209
+ timestamp=int(record.created * 1e9),
210
+ body=masked_body,
211
+ severity_number=_PYTHON_TO_OTEL_SEVERITY.get(level_name, SeverityNumber.INFO),
212
+ severity_text=level_name,
213
+ attributes={
214
+ "manual.trace_id": trace_id,
215
+ "manual.span_id": span_id,
216
+ "is_winston": "false"
217
+ }
218
+ )
219
+ except Exception as e:
220
+ print(f"❌ Owl24 Bridge Error: {e}", file=sys.stderr)
221
+
222
+ class Owl24:
223
+ _tracer_provider = None
224
+ _meter_provider = None
225
+ _logger_provider = None
226
+ _crash_capture_registered = False
227
+ _original_excepthook = None
228
+ _original_threading_excepthook = None
229
+ _system_metrics_instrumentor = None
230
+ _active_auto_instrumentors = []
231
+
232
+ @classmethod
233
+ def _record_fatal(cls, origin, exc_type, exc_value, exc_tb):
234
+ """Logs a FATAL-severity event for an uncaught exception and forces
235
+ a bounded-time flush - without this, a crash on the main thread
236
+ (which Python will exit right after, once the excepthook returns)
237
+ could exit before the batch processor's own timer ever fires, and
238
+ the crash telemetry would never actually be sent.
239
+ """
240
+ try:
241
+ message = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
242
+ if cls._logger_provider:
243
+ otel_logger = cls._logger_provider.get_logger("crash-capture")
244
+ otel_logger.emit(
245
+ body=mask_sensitive_data(f"[{origin}] {message}"),
246
+ severity_number=SeverityNumber.FATAL,
247
+ severity_text="FATAL",
248
+ attributes={"error.type": origin},
249
+ )
250
+ cls._logger_provider.force_flush(timeout_millis=5000)
251
+ if cls._tracer_provider:
252
+ cls._tracer_provider.force_flush(timeout_millis=5000)
253
+ except Exception as e:
254
+ print(f"❌ [Owl24] Failed to record crash event: {e}", file=sys.stderr)
255
+
256
+ @classmethod
257
+ def _setup_crash_capture(cls):
258
+ if cls._crash_capture_registered:
259
+ return
260
+ cls._crash_capture_registered = True
261
+
262
+ cls._original_excepthook = sys.excepthook
263
+
264
+ def excepthook(exc_type, exc_value, exc_tb):
265
+ cls._record_fatal("uncaught_exception", exc_type, exc_value, exc_tb)
266
+ # Chain to the previous hook (Python's default one, unless
267
+ # something else already replaced it) so the normal traceback
268
+ # still prints and the interpreter still exits as it always
269
+ # would - this only adds capture+flush, it doesn't change
270
+ # whether/how the process exits.
271
+ (cls._original_excepthook or sys.__excepthook__)(exc_type, exc_value, exc_tb)
272
+
273
+ sys.excepthook = excepthook
274
+
275
+ # An uncaught exception in a background thread only kills that
276
+ # thread by default, not the process - still worth capturing since
277
+ # it's telemetry the app would otherwise lose entirely.
278
+ if hasattr(threading, "excepthook"):
279
+ cls._original_threading_excepthook = threading.excepthook
280
+
281
+ def threading_excepthook(args):
282
+ cls._record_fatal("uncaught_exception_thread", args.exc_type, args.exc_value, args.exc_traceback)
283
+ (cls._original_threading_excepthook or threading.__excepthook__)(args)
284
+
285
+ threading.excepthook = threading_excepthook
286
+
287
+ @classmethod
288
+ def init(cls, api_key=None, service_name="dice-server", export_interval_millis=3000,
289
+ disable_console_bridge=False, disable_crash_capture=False, export_timeout_millis=5000,
290
+ disable_auto_instrumentation=False):
291
+ resolved_api_key = api_key or os.getenv("owl24_API_KEY") or os.getenv("OBSERVE_API_KEY")
292
+ user_email = os.getenv("owl24_USER_EMAIL") or os.getenv("OBSERVE_USER_EMAIL") or "unknown@local.dev"
293
+ # Hardcoded, not configurable: owl24 is a fully-hosted service with
294
+ # one fixed ingest endpoint - unlike the API key (which is
295
+ # per-customer) or user email, there's nothing for a caller to
296
+ # legitimately point this at instead.
297
+ ingest_base_url = "https://ingest.owl24.dev"
298
+
299
+ if not resolved_api_key:
300
+ print("❌ [Owl24] API Key required.", file=sys.stderr)
301
+ return
302
+
303
+ # Any failure below (bad ingest URL, exporter/provider construction
304
+ # error) must not crash the host application - an observability
305
+ # SDK failing to initialize should degrade to a no-op, not take the
306
+ # customer's app down with it.
307
+ try:
308
+ headers = {"x-api-key": resolved_api_key, "x-user-email": user_email}
309
+ resource = Resource.create({
310
+ SERVICE_NAME: service_name,
311
+ SERVICE_VERSION: "0.1.0",
312
+ })
313
+
314
+ # Explicit per-attempt timeout on every exporter - without this,
315
+ # each export attempt falls back to the OTel SDK's own default
316
+ # (10s), and a single unreachable-endpoint retry can then run
317
+ # well past force_flush()'s own timeout_millis (that timeout
318
+ # only bounds "wait for the worker to report back", not an
319
+ # export call already in flight). This is what makes
320
+ # _record_fatal's crash-time flush actually bounded - verified
321
+ # live: without this, a crash-capture flush against an
322
+ # unreachable endpoint hung past 12s despite a 5s force_flush
323
+ # timeout.
324
+ export_timeout_seconds = export_timeout_millis / 1000
325
+
326
+ cls._tracer_provider = TracerProvider(resource=resource)
327
+ trace_exporter = OTLPSpanExporter(endpoint=f"{ingest_base_url}/v1/traces", headers=headers, timeout=export_timeout_seconds)
328
+ cls._tracer_provider.add_span_processor(
329
+ BatchSpanProcessor(MaskingSpanExporter(trace_exporter), schedule_delay_millis=export_interval_millis)
330
+ )
331
+ trace.set_tracer_provider(cls._tracer_provider)
332
+
333
+ metric_exporter = OTLPMetricExporter(endpoint=f"{ingest_base_url}/v1/metrics", headers=headers, timeout=export_timeout_seconds)
334
+ reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=export_interval_millis)
335
+ cls._meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
336
+ metrics.set_meter_provider(cls._meter_provider)
337
+
338
+ # Host metrics (CPU/memory/network) - the Python equivalent of
339
+ # owl24-js's HostMetrics and owl24-java's runtime-telemetry-java8
340
+ # observers. Unlike those, this was previously entirely missing:
341
+ # a MeterProvider was wired up but nothing ever created a metric
342
+ # instrument, so no metrics data was ever actually produced.
343
+ cls._system_metrics_instrumentor = SystemMetricsInstrumentor()
344
+ cls._system_metrics_instrumentor.instrument()
345
+
346
+ if not disable_auto_instrumentation:
347
+ for name, instrumentor_cls in _AUTO_INSTRUMENTORS:
348
+ try:
349
+ instance = instrumentor_cls()
350
+ instance.instrument()
351
+ cls._active_auto_instrumentors.append(instance)
352
+ except Exception as auto_instrument_error:
353
+ print(f"⚠️ [Owl24] Auto-instrumentation for '{name}' failed: {auto_instrument_error}", file=sys.stderr)
354
+
355
+ cls._logger_provider = LoggerProvider(resource=resource)
356
+ log_exporter = OTLPLogExporter(endpoint=f"{ingest_base_url}/v1/logs", headers=headers, timeout=export_timeout_seconds)
357
+ cls._logger_provider.add_log_record_processor(
358
+ BatchLogRecordProcessor(log_exporter, schedule_delay_millis=export_interval_millis)
359
+ )
360
+ set_logger_provider(cls._logger_provider)
361
+
362
+ if not disable_console_bridge:
363
+ otel_logger = cls._logger_provider.get_logger("console-bridge")
364
+ root_logger = logging.getLogger()
365
+ root_logger.setLevel(logging.INFO)
366
+ root_logger.addHandler(MaskingAndOtelHandler(otel_logger))
367
+
368
+ if not disable_crash_capture:
369
+ cls._setup_crash_capture()
370
+
371
+ print(f"🚀 [Owl24] Universal APM Active: {service_name}")
372
+ except Exception as e:
373
+ print(f"❌ [Owl24] Init failed: {e}", file=sys.stderr)
374
+
375
+ @classmethod
376
+ def shutdown(cls):
377
+ print("Shutting down telemetry...")
378
+ if cls._tracer_provider: cls._tracer_provider.shutdown()
379
+ if cls._logger_provider: cls._logger_provider.shutdown()
380
+ if cls._meter_provider: cls._meter_provider.shutdown()
381
+ if cls._system_metrics_instrumentor:
382
+ try:
383
+ cls._system_metrics_instrumentor.uninstrument()
384
+ except Exception as e:
385
+ print(f"❌ [Owl24] Failed to uninstrument system metrics: {e}", file=sys.stderr)
386
+ for instrumentor in cls._active_auto_instrumentors:
387
+ try:
388
+ instrumentor.uninstrument()
389
+ except Exception as e:
390
+ print(f"❌ [Owl24] Failed to uninstrument {instrumentor}: {e}", file=sys.stderr)
391
+ cls._active_auto_instrumentors = []
392
+
393
+ def handle_sigterm(signum, frame):
394
+ Owl24.shutdown()
395
+ sys.exit(0)
396
+
397
+ signal.signal(signal.SIGTERM, handle_sigterm)