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.
- owl24_py-0.1.0/.gitignore +46 -0
- owl24_py-0.1.0/LICENSE +21 -0
- owl24_py-0.1.0/PKG-INFO +106 -0
- owl24_py-0.1.0/README.md +77 -0
- owl24_py-0.1.0/pyproject.toml +52 -0
- owl24_py-0.1.0/src/owl24_py/__init__.py +1 -0
- owl24_py-0.1.0/src/owl24_py/telemetry.py +397 -0
|
@@ -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.
|
owl24_py-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
owl24_py-0.1.0/README.md
ADDED
|
@@ -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)
|