Skip to content

Commit 66d2cdc

Browse files
committed
Add plain.connect toolbar link to exported traces
When export is active, plain.connect adds a toolbar item linking the current request to its trace in Plain Cloud, via a /t/<trace_id> short URL built from the new CONNECT_DASHBOARD_URL setting.
1 parent c4283e4 commit 66d2cdc

10 files changed

Lines changed: 158 additions & 1 deletion

File tree

example/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ app/assets/tailwind.min.css
22
/db.sqlite3
33
.claude/skills
44
.claude/rules
5+
.env.local
6+
.env.*.local

plain-connect/plain/connect/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- [What gets exported](#what-gets-exported)
99
- [Pageview tracking](#pageview-tracking)
1010
- [Support forms](#support-forms)
11+
- [Toolbar](#toolbar)
1112
- [Observer coexistence](#observer-coexistence)
1213
- [FAQs](#faqs)
1314
- [Installation](#installation)
@@ -29,6 +30,7 @@ If `CONNECT_EXPORT_TOKEN` is not set, the package is a no-op — safe to install
2930
| Setting | Default | Description |
3031
| --------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
3132
| `CONNECT_EXPORT_URL` | `"https://ingest.plainframework.com"` | OTLP ingest endpoint (override to use a custom endpoint) |
33+
| `CONNECT_DASHBOARD_URL` | `"https://plainframework.com"` | Plain Cloud dashboard base URL — used for the toolbar's trace links |
3234
| `CONNECT_EXPORT_TOKEN` | `""` | Auth token for the export endpoint |
3335
| `CONNECT_TRACE_SAMPLE_RATE` | `1.0` | Probability of exporting a trace (0.0–1.0) |
3436
| `CONNECT_EXPORT_LOGS` | `True` | Set to `False` to disable OTLP log export |
@@ -137,6 +139,14 @@ Any other field name is captured into the conversation's `extras` JSON dict auto
137139

138140
The endpoint accepts either `application/x-www-form-urlencoded` (the standard `<form action="...">` path) or `application/json` (for fetch-driven submissions). The field names are the same in both shapes. On the form-action path `_next` redirects the browser on success; on the JSON path the response is `{"ok": true, "conversation": "<uuid>"}`.
139141

142+
## Toolbar
143+
144+
If you have [plain.toolbar](../../plain-toolbar/plain/toolbar/README.md) installed, plain.connect adds a **Trace** button that links the current request straight to its trace in Plain Cloud.
145+
146+
The button only appears when export is active (`CONNECT_EXPORT_TOKEN` is set), so it stays out of the way in local dev. It links to a short `/t/<trace_id>` URL on `CONNECT_DASHBOARD_URL`, which resolves the trace to its app and redirects you to the full trace view — no app slug needed in the link.
147+
148+
If a request wasn't sampled for export (see [Sampling](#sampling)), the button shows "Not sampled" instead. Because traces export in a background batch, a freshly-clicked link may briefly land on a "locating trace" page that retries until the trace arrives.
149+
140150
## Observer coexistence
141151

142152
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.

plain-connect/plain/connect/default_settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
CONNECT_EXPORT_ENABLED: bool = True # Set to False to disable all OTEL reporting
44
CONNECT_EXPORT_URL: str = "https://ingest.plainframework.com"
5+
# Base URL of the Plain Cloud dashboard. Used to build links back to exported
6+
# traces — the `/t/<trace_id>` short URL surfaced in the toolbar.
7+
CONNECT_DASHBOARD_URL: str = "https://plainframework.com"
58
CONNECT_EXPORT_TOKEN: Secret[str] = "" # Auth token for the export endpoint
69
CONNECT_TRACE_SAMPLE_RATE: float = 1.0 # 0.0–1.0, probability of exporting a trace
710
CONNECT_EXPORT_LOGS: bool = True # Set to False to disable OTLP log export
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% if trace_url %}
2+
{# Trace exported — link to the span waterfall in Plain Cloud #}
3+
<a
4+
href="{{ trace_url }}"
5+
target="_blank"
6+
rel="noopener"
7+
class="inline-flex items-center gap-1.5 cursor-pointer text-xs rounded-full px-2 py-px bg-white/8 text-white/80 hover:bg-white/12 transition-colors"
8+
title="View this request's trace in Plain Cloud"
9+
>
10+
<svg class="size-3 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
11+
<rect x="2" y="2.5" width="12" height="3" rx="1.5"/>
12+
<rect x="5" y="6.5" width="8" height="3" rx="1.5"/>
13+
<rect x="3.5" y="10.5" width="6" height="3" rx="1.5"/>
14+
</svg>
15+
<span class="text-[11px]">Trace</span>
16+
</a>
17+
{% elif sampled is false %}
18+
{# Connect is exporting, but this request was dropped by sampling #}
19+
<span
20+
class="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-px bg-white/8 text-white/40"
21+
title="This request was not sampled for export to Plain Cloud"
22+
>
23+
<svg class="size-3 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
24+
<rect x="2" y="2.5" width="12" height="3" rx="1.5"/>
25+
<rect x="5" y="6.5" width="8" height="3" rx="1.5"/>
26+
<rect x="3.5" y="10.5" width="6" height="3" rx="1.5"/>
27+
</svg>
28+
<span class="text-[11px]">Not sampled</span>
29+
</span>
30+
{% endif %}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from opentelemetry import trace
6+
from opentelemetry.trace import format_trace_id
7+
8+
from plain.runtime import settings
9+
from plain.toolbar import ToolbarItem, register_toolbar_item
10+
11+
12+
@register_toolbar_item
13+
class ConnectToolbarItem(ToolbarItem):
14+
"""Links the current request to its exported trace in Plain Cloud.
15+
16+
This module is only imported when plain.toolbar is installed (it is
17+
autodiscovered by the toolbar package), so the import above is safe
18+
without a guard. The item is a button-only toolbar entry — no panel —
19+
that points at the `/t/<trace_id>` short URL on the dashboard, which
20+
resolves the trace back to its app and redirects.
21+
"""
22+
23+
name = "Connect"
24+
button_template_name = "toolbar/connect_button.html"
25+
26+
def is_enabled(self) -> bool:
27+
"""Only show the item when connect is actively exporting."""
28+
return bool(settings.CONNECT_EXPORT_ENABLED and settings.CONNECT_EXPORT_TOKEN)
29+
30+
def get_template_context(self) -> dict[str, Any]:
31+
context = super().get_template_context()
32+
33+
span_context = trace.get_current_span().get_span_context()
34+
if not span_context.is_valid:
35+
context["sampled"] = None
36+
context["trace_url"] = ""
37+
return context
38+
39+
# The sampled flag reflects the final sampling decision, so a
40+
# sub-1.0 CONNECT_TRACE_SAMPLE_RATE is accounted for here for free.
41+
context["sampled"] = span_context.trace_flags.sampled
42+
if span_context.trace_flags.sampled:
43+
dashboard_url = str(settings.CONNECT_DASHBOARD_URL).rstrip("/")
44+
trace_id = format_trace_id(span_context.trace_id)
45+
context["trace_url"] = f"{dashboard_url}/t/{trace_id}"
46+
else:
47+
context["trace_url"] = ""
48+
49+
return context

plain-connect/pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ dependencies = [
1414
]
1515

1616
[dependency-groups]
17-
dev = ["plain.pytest<1.0.0", "plain.auth<1.0.0", "plain.assets<1.0.0", "plain.templates<1.0.0"]
17+
dev = [
18+
"plain.pytest<1.0.0",
19+
"plain.auth<1.0.0",
20+
"plain.assets<1.0.0",
21+
"plain.templates<1.0.0",
22+
"plain.toolbar<1.0.0",
23+
]
1824

1925
[tool.hatch.build.targets.wheel]
2026
packages = ["plain"]

plain-connect/tests/app/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
INSTALLED_PACKAGES = [
44
"plain.assets",
55
"plain.templates",
6+
"plain.toolbar",
67
"plain.sessions",
78
"plain.auth",
89
"plain.postgres",

plain-connect/tests/app/templates/page.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
<textarea name="message"></textarea>
1010
<button type="submit">Send</button>
1111
</form>
12+
{% toolbar %}
1213
</body>
1314
</html>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import re
2+
3+
import pytest
4+
5+
from plain.test import Client
6+
7+
8+
@pytest.fixture
9+
def real_tracing():
10+
"""Install a real SDK TracerProvider so requests get valid, sampled spans.
11+
12+
Without an SDK provider, OpenTelemetry hands out no-op spans whose context
13+
is invalid (all-zero trace id). The toolbar item needs a real trace id to
14+
build a link, so the export-link tests opt into this. connect itself only
15+
installs a provider when an export token is set at startup, which the test
16+
settings don't do.
17+
"""
18+
from opentelemetry import trace
19+
from opentelemetry.sdk.trace import TracerProvider
20+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
21+
22+
if isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider):
23+
trace.set_tracer_provider(TracerProvider(sampler=ALWAYS_ON))
24+
25+
26+
def test_no_trace_button_when_export_is_not_configured(db, settings):
27+
# The toolbar renders in DEBUG, but with no CONNECT_EXPORT_TOKEN the
28+
# connect item disables itself and contributes nothing.
29+
settings.DEBUG = True
30+
response = Client().get("/")
31+
assert response.status_code == 200
32+
assert b"plainframework.com/t/" not in response.content
33+
34+
35+
def test_trace_button_links_to_the_dashboard(db, settings, real_tracing):
36+
# The test suite exports PLAIN_CONNECT_EXPORT_ENABLED=false, so re-enable it.
37+
settings.DEBUG = True
38+
settings.CONNECT_EXPORT_ENABLED = True
39+
settings.CONNECT_EXPORT_TOKEN = "test-token"
40+
response = Client().get("/")
41+
assert response.status_code == 200
42+
match = re.search(rb"https://plainframework\.com/t/[0-9a-f]{32}", response.content)
43+
assert match, f"no trace link in toolbar: {response.content!r}"
44+
45+
46+
def test_trace_link_uses_the_configured_dashboard_url(db, settings, real_tracing):
47+
settings.DEBUG = True
48+
settings.CONNECT_EXPORT_ENABLED = True
49+
settings.CONNECT_EXPORT_TOKEN = "test-token"
50+
settings.CONNECT_DASHBOARD_URL = "https://cloud.example.com"
51+
response = Client().get("/")
52+
assert response.status_code == 200
53+
assert re.search(rb"https://cloud\.example\.com/t/[0-9a-f]{32}", response.content)

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)