plain.connect 0.3.4__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,26 @@
1
+ .venv
2
+ /.env
3
+ *.egg-info
4
+ *.py[co]
5
+ __pycache__
6
+ *.DS_Store
7
+ *.swp
8
+ *.swo
9
+
10
+ /*.code-workspace
11
+
12
+ # Test apps
13
+ plain*/tests/.plain
14
+
15
+ # Agent scratch files
16
+ /scratch
17
+
18
+ # Plain temp dirs
19
+ .plain
20
+
21
+ .vscode
22
+ /.claude/settings.local.json
23
+ /.claude/skills/announcements/
24
+ /CLAUDE.local.md
25
+ /.benchmarks
26
+ .claude/worktrees
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Dropseed, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.connect
3
+ Version: 0.3.4
4
+ Summary: Connect your Plain app to Plain Cloud via OTLP export.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.34.1
10
+ Requires-Dist: opentelemetry-sdk>=1.34.1
11
+ Requires-Dist: plain<1.0.0,>=0.113.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # plain.connect
15
+
16
+ **Connect your Plain app to Plain Cloud via OTLP export.**
17
+
18
+ - [Overview](#overview)
19
+ - [Settings](#settings)
20
+ - [Sampling](#sampling)
21
+ - [What gets exported](#what-gets-exported)
22
+ - [Observer coexistence](#observer-coexistence)
23
+ - [FAQs](#faqs)
24
+ - [Installation](#installation)
25
+
26
+ ## Overview
27
+
28
+ You can use plain.connect to export traces, metrics, and logs from your Plain app to Plain Cloud. The framework already instruments itself with OpenTelemetry spans and histograms — plain.connect activates them by providing the OTLP exporters and bridges Python's `logging` module into OTLP log records.
29
+
30
+ Set one environment variable and your app starts pushing telemetry:
31
+
32
+ ```
33
+ PLAIN_CONNECT_EXPORT_TOKEN=your-token
34
+ ```
35
+
36
+ If `CONNECT_EXPORT_TOKEN` is not set, the package is a no-op — safe to install without configuration.
37
+
38
+ ## Settings
39
+
40
+ | Setting | Default | Description |
41
+ | --------------------------- | ------------------------------------- | ----------------------------------------------------------- |
42
+ | `CONNECT_EXPORT_URL` | `"https://ingest.plainframework.com"` | OTLP ingest endpoint (override to use a custom endpoint) |
43
+ | `CONNECT_EXPORT_TOKEN` | `""` | Auth token for the export endpoint |
44
+ | `CONNECT_TRACE_SAMPLE_RATE` | `1.0` | Probability of exporting a trace (0.0–1.0) |
45
+ | `CONNECT_EXPORT_LOGS` | `True` | Set to `False` to disable OTLP log export |
46
+ | `CONNECT_LOG_LEVEL` | `"INFO"` | Minimum severity exported via OTLP logs (level name or int) |
47
+
48
+ All settings can be set via `PLAIN_`-prefixed environment variables or in `app/settings.py`.
49
+
50
+ ## Sampling
51
+
52
+ By default, all traces are exported. To reduce volume, set a sample rate:
53
+
54
+ ```python
55
+ CONNECT_TRACE_SAMPLE_RATE = 0.1 # Export 10% of traces
56
+ ```
57
+
58
+ Metrics are not affected by sampling — histograms aggregate in-process and export periodically regardless of the trace sample rate.
59
+
60
+ ## What gets exported
61
+
62
+ **Traces** — HTTP request spans and database query spans instrumented by the framework.
63
+
64
+ **Metrics** — OTel histograms like `db.client.query.duration`, aggregated and pushed every 60 seconds.
65
+
66
+ **Logs** — Records from the `plain` and `app` loggers, plus anything propagating to the root logger, are bridged into OTLP log records and exported with `trace_id` / `span_id` set from the active span. The minimum severity is controlled by `CONNECT_LOG_LEVEL` (default `INFO`); the root logger's level is widened to that floor when needed so libraries using `getLogger(__name__)` reach the exporter. To prevent feedback loops, two sources are skipped on the export path: the `opentelemetry` namespace, and any record emitted from inside the OTLP exporter's background thread (e.g. urllib3 connection errors raised by the exporter's own HTTP call). Your application's urllib3 logs are exported normally.
67
+
68
+ ## Observer coexistence
69
+
70
+ If [plain.observer](../../plain-observer/plain/observer/README.md) is also installed, both work simultaneously. plain.connect handles production export while observer provides the local dev toolbar and admin trace viewer. Observer detects the existing TracerProvider and layers its sampler and span processor on top.
71
+
72
+ ## FAQs
73
+
74
+ #### Do I need plain.observer to use plain.connect?
75
+
76
+ No. plain.connect works independently. Observer is for local dev tooling; plain.connect is for production export.
77
+
78
+ #### What happens if the export endpoint is unreachable?
79
+
80
+ The OTLP exporters batch and retry automatically. If the endpoint is down, telemetry is dropped after retries — it does not block your application.
81
+
82
+ #### Does this add latency to requests?
83
+
84
+ No. Trace spans are exported in a background thread via `BatchSpanProcessor`. Metrics are flushed periodically by a background thread. Neither blocks request handling.
85
+
86
+ ## Installation
87
+
88
+ ```python
89
+ # app/settings.py
90
+ INSTALLED_PACKAGES = [
91
+ "plain.connect",
92
+ # ...
93
+ ]
94
+ ```
95
+
96
+ Place `plain.connect` **before** `plain.observer` in `INSTALLED_PACKAGES` so it sets up the TracerProvider first.
@@ -0,0 +1 @@
1
+ plain/connect/README.md
@@ -0,0 +1,122 @@
1
+ # plain-connect changelog
2
+
3
+ ## [0.3.4](https://github.com/dropseed/plain/releases/plain-connect@0.3.4) (2026-05-07)
4
+
5
+ ### What's changed
6
+
7
+ - **Renamed `plain-cloud` to `plain-connect`.** The package, module path, and config label all change: `plain.cloud` → `plain.connect`, and the package label `plaincloud` → `plainconnect`. All settings move from the `CLOUD_*` prefix to `CONNECT_*` (e.g. `CLOUD_EXPORT_TOKEN` → `CONNECT_EXPORT_TOKEN`, `PLAIN_CLOUD_EXPORT_TOKEN` → `PLAIN_CONNECT_EXPORT_TOKEN`). The destination service is still Plain Cloud — `plain-connect` is the app integration package that ships telemetry to it. ([304fc185cc](https://github.com/dropseed/plain/commit/304fc185cc))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - Replace `plain-cloud` with `plain-connect` in your dependencies (e.g. `pyproject.toml`).
12
+ - In `app/settings.py`, replace `"plain.cloud"` with `"plain.connect"` in `INSTALLED_PACKAGES`.
13
+ - Rename any `CLOUD_*` settings to `CONNECT_*`, and any `PLAIN_CLOUD_*` env vars to `PLAIN_CONNECT_*`.
14
+
15
+ ## [0.3.3](https://github.com/dropseed/plain/releases/plain-connect@0.3.3) (2026-05-05)
16
+
17
+ ### What's changed
18
+
19
+ - Exposes `__version__` from `importlib.metadata` on `plain.cloud` for version probes that don't want to scrape pip metadata. ([c6cf6edb](https://github.com/dropseed/plain/commit/c6cf6edb))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required.
24
+
25
+ ## [0.3.2](https://github.com/dropseed/plain/releases/plain-connect@0.3.2) (2026-04-30)
26
+
27
+ ### What's changed
28
+
29
+ - **Suppressed Sentry capture for OTLP exporter batch failures.** The OpenTelemetry SDK's exporters log `"Failed to export X batch"` at ERROR after retries are exhausted, which Sentry's `LoggingIntegration` would otherwise turn into an issue per app per incident — noise the app owner can't act on (network/edge timeouts, ingest backend hiccups). The records still flow to console/file/etc.; only the Sentry capture is suppressed. Mirrors the Sentry SDK's own self-protection for `sentry_sdk.errors` and `urllib3.connectionpool`. ([eb771d82d2de](https://github.com/dropseed/plain/commit/eb771d82d2de))
30
+
31
+ ### Upgrade instructions
32
+
33
+ - No changes required.
34
+
35
+ ## [0.3.1](https://github.com/dropseed/plain/releases/plain-connect@0.3.1) (2026-04-28)
36
+
37
+ ### What's changed
38
+
39
+ - The OTLP span, metric, and log exporters now use gzip compression and a 30-second timeout, reducing egress bandwidth and giving slow ingest endpoints more headroom before requests are dropped. ([891864bcf710](https://github.com/dropseed/plain/commit/891864bcf710))
40
+
41
+ ### Upgrade instructions
42
+
43
+ - No changes required.
44
+
45
+ ## [0.3.0](https://github.com/dropseed/plain/releases/plain-connect@0.3.0) (2026-04-27)
46
+
47
+ ### What's changed
48
+
49
+ - **Added OTLP log export.** Records from the `plain` and `app` loggers, plus anything propagating to the root logger, are bridged into OTLP log records and exported alongside traces and metrics, with `trace_id` / `span_id` populated from the active span. Two new settings: `CLOUD_EXPORT_LOGS` (default `True`) and `CLOUD_LOG_LEVEL` (default `"INFO"`, accepts a level name or int). The root logger's effective level is widened upward to `CLOUD_LOG_LEVEL` when narrower so libraries using `getLogger(__name__)` reach the exporter; it is never narrowed. To prevent feedback loops under transport failure, the exporter ignores records from the `opentelemetry` namespace and from any OTel SDK exporter thread (`OtelBatchSpanRecordProcessor`, `OtelBatchLogRecordProcessor`, `OtelPeriodicExportingMetricReader`). Application urllib3 logs are exported normally. ([3937adee2153](https://github.com/dropseed/plain/commit/3937adee2153))
50
+ - Added a `LoggerProvider` collision check that mirrors the existing `TracerProvider` check, so `plain.cloud` will fail loudly with the "list before plain.observer" message if another package has already installed a logger provider. ([3937adee2153](https://github.com/dropseed/plain/commit/3937adee2153))
51
+
52
+ ### Upgrade instructions
53
+
54
+ - No changes required. To opt out of log export, set `CLOUD_EXPORT_LOGS=False` (or `PLAIN_CLOUD_EXPORT_LOGS=false`). To raise/lower the severity floor, set `CLOUD_LOG_LEVEL` (e.g. `"WARNING"`).
55
+
56
+ ## [0.2.0](https://github.com/dropseed/plain/releases/plain-connect@0.2.0) (2026-04-27)
57
+
58
+ ### What's changed
59
+
60
+ - **Changed the default `CLOUD_EXPORT_URL` to `https://ingest.plainframework.com`** (was `https://plainframework.com/otel`). Projects relying on the default will now export to the dedicated ingest subdomain. ([e58c02eaab9e](https://github.com/dropseed/plain/commit/e58c02eaab9e))
61
+
62
+ ### Upgrade instructions
63
+
64
+ - If you were depending on the previous default, set `PLAIN_CLOUD_EXPORT_URL=https://plainframework.com/otel` (or assign `CLOUD_EXPORT_URL` in `app/settings.py`) to keep the old endpoint. Otherwise no changes required.
65
+
66
+ ## [0.1.5](https://github.com/dropseed/plain/releases/plain-connect@0.1.5) (2026-04-13)
67
+
68
+ ### What's changed
69
+
70
+ - Removed redundant `atexit` shutdown registrations that duplicated the shutdown hooks already registered elsewhere. ([dfb2ce53cd5c](https://github.com/dropseed/plain/commit/dfb2ce53cd5c))
71
+
72
+ ### Upgrade instructions
73
+
74
+ - No changes required.
75
+
76
+ ## [0.1.4](https://github.com/dropseed/plain/releases/plain-connect@0.1.4) (2026-04-02)
77
+
78
+ ### What's changed
79
+
80
+ - Switched metrics export to delta temporality for Counter, Histogram, and UpDownCounter. Each export now contains only the increment since the last collection, making server-side aggregation in ClickHouse straightforward. ([ab431cb5ffe6](https://github.com/dropseed/plain/commit/ab431cb5ffe6))
81
+
82
+ ### Upgrade instructions
83
+
84
+ - No changes required.
85
+
86
+ ## [0.1.3](https://github.com/dropseed/plain/releases/plain-connect@0.1.3) (2026-04-01)
87
+
88
+ ### What's changed
89
+
90
+ - Added `CLOUD_EXPORT_ENABLED` setting (defaults to `True`) to allow disabling all OTEL reporting without removing the token. Set `PLAIN_CLOUD_EXPORT_ENABLED=false` to turn it off. ([e9c4d140b227](https://github.com/dropseed/plain/commit/e9c4d140b227))
91
+ - Raises `RuntimeError` if another tracer provider is already configured when plain.cloud initializes — ensures `plain.cloud` is listed before `plain.observer` in `INSTALLED_PACKAGES`. ([40252d96ce7d](https://github.com/dropseed/plain/commit/40252d96ce7d))
92
+
93
+ ### Upgrade instructions
94
+
95
+ - No changes required.
96
+
97
+ ## [0.1.2](https://github.com/dropseed/plain/releases/plain-connect@0.1.2) (2026-04-01)
98
+
99
+ ### What's changed
100
+
101
+ - `CLOUD_EXPORT_URL` now defaults to `https://plainframework.com/otel` — no need to set it manually. Export is gated on `CLOUD_EXPORT_TOKEN` instead, so only one env var is needed to start pushing telemetry. ([fa711758acda](https://github.com/dropseed/plain/commit/fa711758acda))
102
+
103
+ ### Upgrade instructions
104
+
105
+ - If you had `PLAIN_CLOUD_EXPORT_URL` set to `https://plainframework.com/otel`, you can remove it — that's now the default.
106
+ - If you relied on leaving `CLOUD_EXPORT_URL` empty to disable export, set `CLOUD_EXPORT_TOKEN` to empty instead (or just don't set it).
107
+
108
+ ## [0.1.1](https://github.com/dropseed/plain/releases/plain-connect@0.1.1) (2026-04-01)
109
+
110
+ ### What's changed
111
+
112
+ - Updated export endpoint URLs in docs and default settings from `plaincloud.com` to `plainframework.com/otel`. ([15bb896cdbe6](https://github.com/dropseed/plain/commit/15bb896cdbe6))
113
+
114
+ ### Upgrade instructions
115
+
116
+ - If you have `PLAIN_CLOUD_EXPORT_URL` set to `https://ingest.plaincloud.com`, update it to `https://plainframework.com/otel`.
117
+
118
+ ## [0.1.0](https://github.com/dropseed/plain/releases/plain-connect@0.1.0) (2026-04-01)
119
+
120
+ ### What's changed
121
+
122
+ - **Initial release.** Sets up OpenTelemetry TracerProvider and MeterProvider with OTLP HTTP exporters, pushing traces and metrics to Plain Cloud. Configure with `CLOUD_EXPORT_URL` and `CLOUD_EXPORT_TOKEN` settings. Includes head-based trace sampling via `CLOUD_TRACE_SAMPLE_RATE`. Inactive when `CLOUD_EXPORT_URL` is not set. Coexists with plain-observer — observer layers its sampler and span processor on top. ([e3971506cb](https://github.com/dropseed/plain/commit/e3971506cb))
@@ -0,0 +1,83 @@
1
+ # plain.connect
2
+
3
+ **Connect your Plain app to Plain Cloud via OTLP export.**
4
+
5
+ - [Overview](#overview)
6
+ - [Settings](#settings)
7
+ - [Sampling](#sampling)
8
+ - [What gets exported](#what-gets-exported)
9
+ - [Observer coexistence](#observer-coexistence)
10
+ - [FAQs](#faqs)
11
+ - [Installation](#installation)
12
+
13
+ ## Overview
14
+
15
+ You can use plain.connect to export traces, metrics, and logs from your Plain app to Plain Cloud. The framework already instruments itself with OpenTelemetry spans and histograms — plain.connect activates them by providing the OTLP exporters and bridges Python's `logging` module into OTLP log records.
16
+
17
+ Set one environment variable and your app starts pushing telemetry:
18
+
19
+ ```
20
+ PLAIN_CONNECT_EXPORT_TOKEN=your-token
21
+ ```
22
+
23
+ If `CONNECT_EXPORT_TOKEN` is not set, the package is a no-op — safe to install without configuration.
24
+
25
+ ## Settings
26
+
27
+ | Setting | Default | Description |
28
+ | --------------------------- | ------------------------------------- | ----------------------------------------------------------- |
29
+ | `CONNECT_EXPORT_URL` | `"https://ingest.plainframework.com"` | OTLP ingest endpoint (override to use a custom endpoint) |
30
+ | `CONNECT_EXPORT_TOKEN` | `""` | Auth token for the export endpoint |
31
+ | `CONNECT_TRACE_SAMPLE_RATE` | `1.0` | Probability of exporting a trace (0.0–1.0) |
32
+ | `CONNECT_EXPORT_LOGS` | `True` | Set to `False` to disable OTLP log export |
33
+ | `CONNECT_LOG_LEVEL` | `"INFO"` | Minimum severity exported via OTLP logs (level name or int) |
34
+
35
+ All settings can be set via `PLAIN_`-prefixed environment variables or in `app/settings.py`.
36
+
37
+ ## Sampling
38
+
39
+ By default, all traces are exported. To reduce volume, set a sample rate:
40
+
41
+ ```python
42
+ CONNECT_TRACE_SAMPLE_RATE = 0.1 # Export 10% of traces
43
+ ```
44
+
45
+ Metrics are not affected by sampling — histograms aggregate in-process and export periodically regardless of the trace sample rate.
46
+
47
+ ## What gets exported
48
+
49
+ **Traces** — HTTP request spans and database query spans instrumented by the framework.
50
+
51
+ **Metrics** — OTel histograms like `db.client.query.duration`, aggregated and pushed every 60 seconds.
52
+
53
+ **Logs** — Records from the `plain` and `app` loggers, plus anything propagating to the root logger, are bridged into OTLP log records and exported with `trace_id` / `span_id` set from the active span. The minimum severity is controlled by `CONNECT_LOG_LEVEL` (default `INFO`); the root logger's level is widened to that floor when needed so libraries using `getLogger(__name__)` reach the exporter. To prevent feedback loops, two sources are skipped on the export path: the `opentelemetry` namespace, and any record emitted from inside the OTLP exporter's background thread (e.g. urllib3 connection errors raised by the exporter's own HTTP call). Your application's urllib3 logs are exported normally.
54
+
55
+ ## Observer coexistence
56
+
57
+ If [plain.observer](../../plain-observer/plain/observer/README.md) is also installed, both work simultaneously. plain.connect handles production export while observer provides the local dev toolbar and admin trace viewer. Observer detects the existing TracerProvider and layers its sampler and span processor on top.
58
+
59
+ ## FAQs
60
+
61
+ #### Do I need plain.observer to use plain.connect?
62
+
63
+ No. plain.connect works independently. Observer is for local dev tooling; plain.connect is for production export.
64
+
65
+ #### What happens if the export endpoint is unreachable?
66
+
67
+ The OTLP exporters batch and retry automatically. If the endpoint is down, telemetry is dropped after retries — it does not block your application.
68
+
69
+ #### Does this add latency to requests?
70
+
71
+ No. Trace spans are exported in a background thread via `BatchSpanProcessor`. Metrics are flushed periodically by a background thread. Neither blocks request handling.
72
+
73
+ ## Installation
74
+
75
+ ```python
76
+ # app/settings.py
77
+ INSTALLED_PACKAGES = [
78
+ "plain.connect",
79
+ # ...
80
+ ]
81
+ ```
82
+
83
+ Place `plain.connect` **before** `plain.observer` in `INSTALLED_PACKAGES` so it sets up the TracerProvider first.
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("plain.connect")
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+
6
+ from opentelemetry import _logs, metrics, trace
7
+ from opentelemetry._logs._internal import ProxyLoggerProvider
8
+ from opentelemetry.exporter.otlp.proto.http import Compression
9
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
10
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
11
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
13
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
14
+ from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider, UpDownCounter
15
+ from opentelemetry.sdk.metrics.export import (
16
+ AggregationTemporality,
17
+ PeriodicExportingMetricReader,
18
+ )
19
+ from opentelemetry.sdk.resources import Resource
20
+ from opentelemetry.sdk.trace import TracerProvider, sampling
21
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
22
+ from opentelemetry.semconv.attributes import service_attributes
23
+
24
+ from plain.packages import PackageConfig, register_config
25
+ from plain.runtime import settings
26
+
27
+
28
+ class _ExporterLoopFilter(logging.Filter):
29
+ """Block records that would feed back into OTLP export under failure.
30
+
31
+ Two sources to suppress:
32
+
33
+ 1. The OpenTelemetry SDK's own namespace — its `failed to export`
34
+ warnings would re-queue indefinitely.
35
+ 2. Anything emitted from inside an OTel SDK exporter thread (e.g.
36
+ urllib3 connection errors raised by the OTLP HTTP exporter). The
37
+ SDK names every exporter thread with an `Otel` prefix —
38
+ `OtelBatch{Span,Log}RecordProcessor` for traces/logs, and
39
+ `OtelPeriodicExportingMetricReader` for metrics — so matching on
40
+ that prefix catches all three. Scoping to the thread, not the
41
+ urllib3 namespace, lets user-code urllib3 logs flow through.
42
+
43
+ See `opentelemetry/sdk/_shared_internal/__init__.py` (BatchProcessor)
44
+ and `opentelemetry/sdk/metrics/export/__init__.py`
45
+ (PeriodicExportingMetricReader) for the thread names.
46
+ """
47
+
48
+ def filter(self, record: logging.LogRecord) -> bool:
49
+ name = record.name
50
+ if name == "opentelemetry" or name.startswith("opentelemetry."):
51
+ return False
52
+ if threading.current_thread().name.startswith("Otel"):
53
+ return False
54
+ return True
55
+
56
+
57
+ @register_config
58
+ class Config(PackageConfig):
59
+ package_label = "plainconnect"
60
+
61
+ def ready(self) -> None:
62
+ if not settings.CONNECT_EXPORT_ENABLED or not settings.CONNECT_EXPORT_TOKEN:
63
+ return
64
+
65
+ # Don't capture per-batch OTLP export failures as Sentry events. The OTel
66
+ # SDK's exporters log "Failed to export X batch" at ERROR after retries
67
+ # are exhausted, which Sentry's LoggingIntegration would otherwise turn
68
+ # into an issue per app per incident — noise the app owner can't act on
69
+ # (network/edge timeouts, ingest backend hiccups). The records still
70
+ # flow to console/file/etc. — only the Sentry capture is suppressed.
71
+ # Mirrors Sentry SDK's own self-protection for `sentry_sdk.errors` and
72
+ # `urllib3.connectionpool` in sentry_sdk/integrations/logging.py.
73
+ try:
74
+ import sentry_sdk.integrations.logging as _sentry_log # ty: ignore[unresolved-import]
75
+ except ImportError:
76
+ pass
77
+ else:
78
+ for name in (
79
+ "opentelemetry.exporter.otlp.proto.http.trace_exporter",
80
+ "opentelemetry.exporter.otlp.proto.http.metric_exporter",
81
+ "opentelemetry.exporter.otlp.proto.http._log_exporter",
82
+ "opentelemetry.sdk._shared_internal",
83
+ "opentelemetry.sdk._logs._internal.export",
84
+ "opentelemetry.sdk.metrics._internal.export",
85
+ ):
86
+ _sentry_log.ignore_logger(name)
87
+
88
+ resource = Resource.create(
89
+ {
90
+ service_attributes.SERVICE_NAME: settings.NAME,
91
+ service_attributes.SERVICE_VERSION: settings.VERSION,
92
+ }
93
+ )
94
+
95
+ export_url = str(settings.CONNECT_EXPORT_URL).rstrip("/")
96
+ headers = {"Authorization": f"Bearer {settings.CONNECT_EXPORT_TOKEN}"}
97
+
98
+ # Traces
99
+ current_provider = trace.get_tracer_provider()
100
+ if current_provider and not isinstance(
101
+ current_provider, trace.ProxyTracerProvider
102
+ ):
103
+ raise RuntimeError(
104
+ "A tracer provider already exists."
105
+ " plain.connect must be listed before plain.observer in INSTALLED_PACKAGES."
106
+ )
107
+
108
+ span_exporter = OTLPSpanExporter(
109
+ endpoint=f"{export_url}/v1/traces",
110
+ headers=headers,
111
+ timeout=30,
112
+ compression=Compression.Gzip,
113
+ )
114
+ sampler = sampling.TraceIdRatioBased(settings.CONNECT_TRACE_SAMPLE_RATE)
115
+ tracer_provider = TracerProvider(sampler=sampler, resource=resource)
116
+ tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
117
+ trace.set_tracer_provider(tracer_provider)
118
+
119
+ # Metrics — use delta temporality so each export contains only the
120
+ # increment since the last export, not a running total. This makes
121
+ # server-side aggregation (sum/avg in ClickHouse) straightforward.
122
+ metric_exporter = OTLPMetricExporter(
123
+ endpoint=f"{export_url}/v1/metrics",
124
+ headers=headers,
125
+ timeout=30,
126
+ compression=Compression.Gzip,
127
+ preferred_temporality={
128
+ Counter: AggregationTemporality.DELTA,
129
+ Histogram: AggregationTemporality.DELTA,
130
+ UpDownCounter: AggregationTemporality.DELTA,
131
+ },
132
+ )
133
+ reader = PeriodicExportingMetricReader(metric_exporter)
134
+ meter_provider = MeterProvider(metric_readers=[reader], resource=resource)
135
+ metrics.set_meter_provider(meter_provider)
136
+
137
+ # Logs
138
+ if settings.CONNECT_EXPORT_LOGS:
139
+ current_logger_provider = _logs.get_logger_provider()
140
+ if current_logger_provider and not isinstance(
141
+ current_logger_provider, ProxyLoggerProvider
142
+ ):
143
+ raise RuntimeError(
144
+ "A logger provider already exists."
145
+ " plain.connect must be listed before plain.observer in INSTALLED_PACKAGES."
146
+ )
147
+
148
+ # Accept either a level name ("INFO") or an int (20).
149
+ raw_level = settings.CONNECT_LOG_LEVEL
150
+ if isinstance(raw_level, str):
151
+ log_level = logging.getLevelName(raw_level.upper())
152
+ if not isinstance(log_level, int):
153
+ raise ValueError(
154
+ f"CONNECT_LOG_LEVEL={raw_level!r} is not a valid logging level."
155
+ )
156
+ else:
157
+ log_level = int(raw_level)
158
+
159
+ log_exporter = OTLPLogExporter(
160
+ endpoint=f"{export_url}/v1/logs",
161
+ headers=headers,
162
+ timeout=30,
163
+ compression=Compression.Gzip,
164
+ )
165
+ logger_provider = LoggerProvider(resource=resource)
166
+ logger_provider.add_log_record_processor(
167
+ BatchLogRecordProcessor(log_exporter)
168
+ )
169
+ _logs.set_logger_provider(logger_provider)
170
+
171
+ handler = LoggingHandler(level=log_level, logger_provider=logger_provider)
172
+ # Filter on the handler (not the loggers) so OTLP exporter and
173
+ # HTTP-client diagnostics still reach the app's console/file
174
+ # handlers — we only stop them from being re-exported, which
175
+ # would loop under failure.
176
+ handler.addFilter(_ExporterLoopFilter())
177
+
178
+ # Plain's `configure_logging` sets `plain` and `app` to
179
+ # propagate=False, so attaching only to root would miss them.
180
+ # Attach to root for everything else (user `getLogger(__name__)`,
181
+ # third-party libs).
182
+ for name in ("", "plain", "app"):
183
+ logging.getLogger(name).addHandler(handler)
184
+
185
+ # Root defaults to WARNING. A library that uses
186
+ # `logging.getLogger(__name__)` without setting its own level
187
+ # inherits root's effective level — so INFO/DEBUG records get
188
+ # dropped before the OTLP handler runs. Widen root just enough
189
+ # to let CONNECT_LOG_LEVEL through; never narrow it.
190
+ # NOTSET (0) on root already means "all messages processed",
191
+ # so leave it alone in that case.
192
+ root = logging.getLogger()
193
+ if root.level != logging.NOTSET and root.level > log_level:
194
+ root.setLevel(log_level)
@@ -0,0 +1,10 @@
1
+ from plain.runtime import Secret
2
+
3
+ CONNECT_EXPORT_ENABLED: bool = True # Set to False to disable all OTEL reporting
4
+ CONNECT_EXPORT_URL: str = "https://ingest.plainframework.com"
5
+ CONNECT_EXPORT_TOKEN: Secret[str] = "" # Auth token for the export endpoint
6
+ CONNECT_TRACE_SAMPLE_RATE: float = 1.0 # 0.0–1.0, probability of exporting a trace
7
+ CONNECT_EXPORT_LOGS: bool = True # Set to False to disable OTLP log export
8
+ # Minimum severity exported via OTLP logs. Accepts a level name ("INFO",
9
+ # "DEBUG", ...) or the integer level value.
10
+ CONNECT_LOG_LEVEL: str = "INFO"
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "plain.connect"
3
+ version = "0.3.4"
4
+ description = "Connect your Plain app to Plain Cloud via OTLP export."
5
+ authors = [{ name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev" }]
6
+ license = "BSD-3-Clause"
7
+ readme = "README.md"
8
+ requires-python = ">=3.13"
9
+ dependencies = [
10
+ "plain>=0.113.0,<1.0.0",
11
+ "opentelemetry-sdk>=1.34.1",
12
+ "opentelemetry-exporter-otlp-proto-http>=1.34.1",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = ["plain.pytest<1.0.0"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["plain"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"