detached ea73af7

Merge pull request #72 from bnema/feat/otel-observability

feat(telemetry): add OpenTelemetry instrumentation for traces, metrics, and logs
commit
ea73af7898109d5d65912a474f5a03bebca3149f
author
bnema · · 2026-02-10 07:52 UTC
committer
GitHub · · 2026-02-10 07:52 UTC
merge of
6ed59e0 + 3408f7e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
17 files · +950 -95
M docs/config/index.md +19 -0
@@ -82,6 +82,16 @@ max_size = 100
8282 max_backups = 3
8383 max_age = 28
8484
85+# Telemetry (OpenTelemetry)
86+[telemetry]
87+enabled = true # Enable OTLP export (default: false)
88+endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint
89+auth_token = "" # Base64 user:password for Basic auth
90+traces = true # Export traces
91+metrics = true # Export metrics
92+logs = true # Bridge zerolog to OTLP logs
93+trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
94+
8595 # Environment variables
8696 [env]
8797 dir = "~/.gordon/env" # Default location
@@ -144,6 +154,7 @@ keep_last = 3
144154 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
145155 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
146156 | `[logging]` | Logging configuration | [Logging](./logging.md) |
157+| `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
147158 | `[env]` | Environment variable settings | [Environment](./env.md) |
148159 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
149160 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
@@ -194,6 +205,13 @@ keep_last = 3
194205 | `images.prune.enabled` | `false` |
195206 | `images.prune.schedule` | `"daily"` |
196207 | `images.prune.keep_last` | `3` |
208+| `telemetry.enabled` | `false` |
209+| `telemetry.endpoint` | `""` |
210+| `telemetry.auth_token` | `""` |
211+| `telemetry.traces` | `true` |
212+| `telemetry.metrics` | `true` |
213+| `telemetry.logs` | `true` |
214+| `telemetry.trace_sample_rate` | `1.0` |
197215
198216 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
199217
@@ -236,5 +254,6 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
236254 - [Routes Configuration](./routes.md)
237255 - [External Routes](./external-routes.md)
238256 - [Authentication](./auth.md)
257+- [Telemetry](./telemetry.md)
239258 - [Backups](./backups.md)
240259 - [Images](./images.md)
@@ -82,6 +82,16 @@ max_size = 100
82 max_backups = 382 max_backups = 3
83 max_age = 2883 max_age = 28
84 84
85+# Telemetry (OpenTelemetry)
86+[telemetry]
87+enabled = true # Enable OTLP export (default: false)
88+endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint
89+auth_token = "" # Base64 user:password for Basic auth
90+traces = true # Export traces
91+metrics = true # Export metrics
92+logs = true # Bridge zerolog to OTLP logs
93+trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
94+
85 # Environment variables95 # Environment variables
86 [env]96 [env]
87 dir = "~/.gordon/env" # Default location97 dir = "~/.gordon/env" # Default location
@@ -144,6 +154,7 @@ keep_last = 3
144 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |154 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
145 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |155 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
146 | `[logging]` | Logging configuration | [Logging](./logging.md) |156 | `[logging]` | Logging configuration | [Logging](./logging.md) |
157+| `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
147 | `[env]` | Environment variable settings | [Environment](./env.md) |158 | `[env]` | Environment variable settings | [Environment](./env.md) |
148 | `[volumes]` | Volume management | [Volumes](./volumes.md) |159 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
149 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |160 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
@@ -194,6 +205,13 @@ keep_last = 3
194 | `images.prune.enabled` | `false` |205 | `images.prune.enabled` | `false` |
195 | `images.prune.schedule` | `"daily"` |206 | `images.prune.schedule` | `"daily"` |
196 | `images.prune.keep_last` | `3` |207 | `images.prune.keep_last` | `3` |
208+| `telemetry.enabled` | `false` |
209+| `telemetry.endpoint` | `""` |
210+| `telemetry.auth_token` | `""` |
211+| `telemetry.traces` | `true` |
212+| `telemetry.metrics` | `true` |
213+| `telemetry.logs` | `true` |
214+| `telemetry.trace_sample_rate` | `1.0` |
197 215
198 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.216 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
199 217
@@ -236,5 +254,6 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
236 - [Routes Configuration](./routes.md)254 - [Routes Configuration](./routes.md)
237 - [External Routes](./external-routes.md)255 - [External Routes](./external-routes.md)
238 - [Authentication](./auth.md)256 - [Authentication](./auth.md)
257+- [Telemetry](./telemetry.md)
239 - [Backups](./backups.md)258 - [Backups](./backups.md)
240 - [Images](./images.md)259 - [Images](./images.md)
M docs/config/reference.md +20 -0
@@ -63,6 +63,18 @@ max_size = 100 # Max size in MB before rotation
6363 max_backups = 3 # Number of old files to keep
6464 max_age = 28 # Days to keep old files
6565
66+# =============================================================================
67+# TELEMETRY (OpenTelemetry)
68+# =============================================================================
69+[telemetry]
70+enabled = false # Enable OTLP telemetry export
71+endpoint = "" # OTLP HTTP endpoint URL
72+auth_token = "" # Base64-encoded user:password for Basic auth
73+traces = true # Export distributed traces
74+metrics = true # Export metrics
75+logs = true # Bridge zerolog output to OTLP logs
76+trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
77+
6678 # =============================================================================
6779 # ENVIRONMENT
6880 # =============================================================================
@@ -186,6 +198,13 @@ keep_last = 3 # Keep N newest tags per repository
186198 | `logging.container_logs.max_size` | `100` | 100 MB |
187199 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
188200 | `logging.container_logs.max_age` | `28` | 28 days |
201+| `telemetry.enabled` | `false` | Enable OTLP telemetry export |
202+| `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL |
203+| `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
204+| `telemetry.traces` | `true` | Export distributed traces |
205+| `telemetry.metrics` | `true` | Export metrics |
206+| `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |
207+| `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |
189208 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
190209 | `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
191210 | `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
@@ -280,6 +299,7 @@ gordon serve
280299
281300 - [Configuration Overview](./index.md)
282301 - [Authentication](./auth.md)
302+- [Telemetry](./telemetry.md)
283303 - [Network Isolation](./network-isolation.md)
284304 - [Volumes](./volumes.md)
285305 - [Images](./images.md)
@@ -63,6 +63,18 @@ max_size = 100 # Max size in MB before rotation
63 max_backups = 3 # Number of old files to keep63 max_backups = 3 # Number of old files to keep
64 max_age = 28 # Days to keep old files64 max_age = 28 # Days to keep old files
65 65
66+# =============================================================================
67+# TELEMETRY (OpenTelemetry)
68+# =============================================================================
69+[telemetry]
70+enabled = false # Enable OTLP telemetry export
71+endpoint = "" # OTLP HTTP endpoint URL
72+auth_token = "" # Base64-encoded user:password for Basic auth
73+traces = true # Export distributed traces
74+metrics = true # Export metrics
75+logs = true # Bridge zerolog output to OTLP logs
76+trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
77+
66 # =============================================================================78 # =============================================================================
67 # ENVIRONMENT79 # ENVIRONMENT
68 # =============================================================================80 # =============================================================================
@@ -186,6 +198,13 @@ keep_last = 3 # Keep N newest tags per repository
186 | `logging.container_logs.max_size` | `100` | 100 MB |198 | `logging.container_logs.max_size` | `100` | 100 MB |
187 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |199 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
188 | `logging.container_logs.max_age` | `28` | 28 days |200 | `logging.container_logs.max_age` | `28` | 28 days |
201+| `telemetry.enabled` | `false` | Enable OTLP telemetry export |
202+| `telemetry.endpoint` | `""` | OTLP HTTP endpoint URL |
203+| `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
204+| `telemetry.traces` | `true` | Export distributed traces |
205+| `telemetry.metrics` | `true` | Export metrics |
206+| `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |
207+| `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |
189 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |208 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
190 | `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |209 | `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
191 | `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |210 | `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
@@ -280,6 +299,7 @@ gordon serve
280 299
281 - [Configuration Overview](./index.md)300 - [Configuration Overview](./index.md)
282 - [Authentication](./auth.md)301 - [Authentication](./auth.md)
302+- [Telemetry](./telemetry.md)
283 - [Network Isolation](./network-isolation.md)303 - [Network Isolation](./network-isolation.md)
284 - [Volumes](./volumes.md)304 - [Volumes](./volumes.md)
285 - [Images](./images.md)305 - [Images](./images.md)
A docs/config/telemetry.md +199 -0
@@ -0,0 +1,199 @@
1+# Telemetry Configuration
2+
3+Export traces, metrics, and logs from Gordon via OpenTelemetry (OTLP/HTTP).
4+
5+When enabled, Gordon ships all three observability signals to an OTLP-compatible backend such as OpenObserve, Jaeger, or Grafana Cloud. Existing zerolog output continues unchanged; the OTel log bridge runs alongside it.
6+
7+## Configuration
8+
9+```toml
10+[telemetry]
11+enabled = false
12+endpoint = ""
13+auth_token = ""
14+traces = true
15+metrics = true
16+logs = true
17+trace_sample_rate = 1.0
18+```
19+
20+## Options
21+
22+| Option | Type | Default | Description |
23+|--------|------|---------|-------------|
24+| `enabled` | bool | `false` | Enable OTLP telemetry export |
25+| `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) |
26+| `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
27+| `traces` | bool | `true` | Export distributed traces |
28+| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |
29+| `logs` | bool | `true` | Bridge zerolog output to OTLP logs |
30+| `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
31+
32+## How It Works
33+
34+Gordon initializes an OTel provider at startup. When telemetry is enabled:
35+
36+1. **Traces** -- Spans wrap critical operations: container deploy, image pull, registry manifest push, and proxy target resolution. The `otelhttp` middleware adds a span to every HTTP request on both the proxy and registry servers.
37+2. **Metrics** -- Gordon records custom counters and histograms for deploys, container restarts, crash loops, managed container count, registry pushes, and event bus throughput.
38+3. **Logs** -- A zerowrap/otel hook bridges all structured log output to the OTLP log pipeline. Every log line automatically carries `trace_id` and `span_id` when emitted inside a traced request.
39+
40+When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.
41+
42+## Endpoint URL
43+
44+The `endpoint` field accepts a full URL. Gordon parses it to extract:
45+
46+- **Host and port** for the OTLP exporter
47+- **Base path** appended with `/v1/traces`, `/v1/metrics`, or `/v1/logs`
48+- **Scheme** -- `http://` disables TLS; `https://` enables it
49+
50+For OpenObserve, the endpoint typically includes the organization path:
51+
52+```text
53+http://localhost:5080/api/default
54+```
55+
56+This produces export URLs like `http://localhost:5080/api/default/v1/traces`.
57+
58+## Authentication
59+
60+Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it as a `Basic` authorization header on every OTLP export request.
61+
62+For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
63+
64+Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods:
65+
66+| Method | How |
67+|--------|-----|
68+| Environment variable | `GORDON_TELEMETRY_AUTH_TOKEN=<token> gordon serve` |
69+| Plain text in config | Set `auth_token = "<token>"` (restrict file permissions) |
70+
71+## Trace Sampling
72+
73+| Value | Behavior |
74+|-------|----------|
75+| `1.0` | Sample every trace (default) |
76+| `0.5` | Sample 50% of traces |
77+| `0.0` | Drop all traces (tracing pipeline stays initialized) |
78+
79+At low traffic volumes (< 1000 req/s), keep the rate at `1.0`. Reduce it if storage costs or export bandwidth become a concern.
80+
81+## Metrics Reference
82+
83+Gordon exports these custom metrics (all prefixed with `gordon.`):
84+
85+### Deployments
86+
87+| Metric | Type | Unit | Description |
88+|--------|------|------|-------------|
89+| `gordon.deploy.total` | Counter | - | Total deployments attempted |
90+| `gordon.deploy.duration_seconds` | Histogram | s | Time from deploy start to completion |
91+| `gordon.deploy.errors` | Counter | - | Deployments that failed |
92+
93+Attributes: `domain`, `image`
94+
95+### Container Lifecycle
96+
97+| Metric | Type | Unit | Description |
98+|--------|------|------|-------------|
99+| `gordon.container.restarts` | Counter | - | Container restart count |
100+| `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101+| `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102+
103+Attributes: `domain`, `source` (restarts only: `monitor` or `api`)
104+
105+### Registry
106+
107+| Metric | Type | Unit | Description |
108+|--------|------|------|-------------|
109+| `gordon.registry.push.total` | Counter | - | Manifest pushes received |
110+| `gordon.registry.push.bytes` | Counter | By | Total manifest bytes pushed |
111+
112+Attributes: `name`, `reference`
113+
114+### Event Bus
115+
116+| Metric | Type | Unit | Description |
117+|--------|------|------|-------------|
118+| `gordon.events.processed` | Counter | - | Events handled successfully |
119+| `gordon.events.dropped` | Counter | - | Events dropped (channel full) |
120+
121+Attributes: `event_type`
122+
123+### HTTP (via otelhttp)
124+
125+The `otelhttp` middleware automatically records standard HTTP server metrics on both the proxy and registry servers:
126+
127+- `http.server.request.duration`
128+- `http.server.request.body.size`
129+- `http.server.response.body.size`
130+
131+## Trace Spans
132+
133+| Span Name | Package | Description |
134+|-----------|---------|-------------|
135+| `container.deploy` | container | Full deploy lifecycle (root span) |
136+| `container.create_and_start` | container | Container creation, start, and readiness |
137+| `container.ensure_image` | container | Image pull and tagging |
138+| `registry.put_manifest` | registry | Manifest storage and event publish |
139+| `proxy.get_target` | proxy | Proxy target resolution and caching |
140+
141+The `otelhttp` middleware adds an HTTP-level span to every request on both servers.
142+
143+## Examples
144+
145+### OpenObserve (self-hosted)
146+
147+```toml
148+[telemetry]
149+enabled = true
150+endpoint = "http://localhost:5080/api/default"
151+auth_token = "YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0"
152+traces = true
153+metrics = true
154+logs = true
155+trace_sample_rate = 1.0
156+```
157+
158+### Grafana Cloud (OTLP)
159+
160+```toml
161+[telemetry]
162+enabled = true
163+endpoint = "https://otlp-gateway-prod-us-east-0.grafana.net/otlp"
164+auth_token = "<instance-id>:<api-key>"
165+traces = true
166+metrics = true
167+logs = true
168+trace_sample_rate = 0.1
169+```
170+
171+### Traces Only (minimal overhead)
172+
173+```toml
174+[telemetry]
175+enabled = true
176+endpoint = "http://localhost:4318"
177+traces = true
178+metrics = false
179+logs = false
180+trace_sample_rate = 0.5
181+```
182+
183+## Environment Variable Override
184+
185+```bash
186+GORDON_TELEMETRY_ENABLED=true
187+GORDON_TELEMETRY_ENDPOINT=http://localhost:5080/api/default
188+GORDON_TELEMETRY_AUTH_TOKEN=YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0
189+GORDON_TELEMETRY_TRACES=true
190+GORDON_TELEMETRY_METRICS=true
191+GORDON_TELEMETRY_LOGS=true
192+GORDON_TELEMETRY_TRACE_SAMPLE_RATE=1.0
193+```
194+
195+## Related
196+
197+- [Configuration Overview](./index.md)
198+- [Logging](./logging.md)
199+- [Configuration Reference](./reference.md)
@@ -0,0 +1,199 @@
1+# Telemetry Configuration
2+
3+Export traces, metrics, and logs from Gordon via OpenTelemetry (OTLP/HTTP).
4+
5+When enabled, Gordon ships all three observability signals to an OTLP-compatible backend such as OpenObserve, Jaeger, or Grafana Cloud. Existing zerolog output continues unchanged; the OTel log bridge runs alongside it.
6+
7+## Configuration
8+
9+```toml
10+[telemetry]
11+enabled = false
12+endpoint = ""
13+auth_token = ""
14+traces = true
15+metrics = true
16+logs = true
17+trace_sample_rate = 1.0
18+```
19+
20+## Options
21+
22+| Option | Type | Default | Description |
23+|--------|------|---------|-------------|
24+| `enabled` | bool | `false` | Enable OTLP telemetry export |
25+| `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) |
26+| `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
27+| `traces` | bool | `true` | Export distributed traces |
28+| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |
29+| `logs` | bool | `true` | Bridge zerolog output to OTLP logs |
30+| `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
31+
32+## How It Works
33+
34+Gordon initializes an OTel provider at startup. When telemetry is enabled:
35+
36+1. **Traces** -- Spans wrap critical operations: container deploy, image pull, registry manifest push, and proxy target resolution. The `otelhttp` middleware adds a span to every HTTP request on both the proxy and registry servers.
37+2. **Metrics** -- Gordon records custom counters and histograms for deploys, container restarts, crash loops, managed container count, registry pushes, and event bus throughput.
38+3. **Logs** -- A zerowrap/otel hook bridges all structured log output to the OTLP log pipeline. Every log line automatically carries `trace_id` and `span_id` when emitted inside a traced request.
39+
40+When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.
41+
42+## Endpoint URL
43+
44+The `endpoint` field accepts a full URL. Gordon parses it to extract:
45+
46+- **Host and port** for the OTLP exporter
47+- **Base path** appended with `/v1/traces`, `/v1/metrics`, or `/v1/logs`
48+- **Scheme** -- `http://` disables TLS; `https://` enables it
49+
50+For OpenObserve, the endpoint typically includes the organization path:
51+
52+```text
53+http://localhost:5080/api/default
54+```
55+
56+This produces export URLs like `http://localhost:5080/api/default/v1/traces`.
57+
58+## Authentication
59+
60+Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it as a `Basic` authorization header on every OTLP export request.
61+
62+For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI.
63+
64+Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods:
65+
66+| Method | How |
67+|--------|-----|
68+| Environment variable | `GORDON_TELEMETRY_AUTH_TOKEN=<token> gordon serve` |
69+| Plain text in config | Set `auth_token = "<token>"` (restrict file permissions) |
70+
71+## Trace Sampling
72+
73+| Value | Behavior |
74+|-------|----------|
75+| `1.0` | Sample every trace (default) |
76+| `0.5` | Sample 50% of traces |
77+| `0.0` | Drop all traces (tracing pipeline stays initialized) |
78+
79+At low traffic volumes (< 1000 req/s), keep the rate at `1.0`. Reduce it if storage costs or export bandwidth become a concern.
80+
81+## Metrics Reference
82+
83+Gordon exports these custom metrics (all prefixed with `gordon.`):
84+
85+### Deployments
86+
87+| Metric | Type | Unit | Description |
88+|--------|------|------|-------------|
89+| `gordon.deploy.total` | Counter | - | Total deployments attempted |
90+| `gordon.deploy.duration_seconds` | Histogram | s | Time from deploy start to completion |
91+| `gordon.deploy.errors` | Counter | - | Deployments that failed |
92+
93+Attributes: `domain`, `image`
94+
95+### Container Lifecycle
96+
97+| Metric | Type | Unit | Description |
98+|--------|------|------|-------------|
99+| `gordon.container.restarts` | Counter | - | Container restart count |
100+| `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101+| `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102+
103+Attributes: `domain`, `source` (restarts only: `monitor` or `api`)
104+
105+### Registry
106+
107+| Metric | Type | Unit | Description |
108+|--------|------|------|-------------|
109+| `gordon.registry.push.total` | Counter | - | Manifest pushes received |
110+| `gordon.registry.push.bytes` | Counter | By | Total manifest bytes pushed |
111+
112+Attributes: `name`, `reference`
113+
114+### Event Bus
115+
116+| Metric | Type | Unit | Description |
117+|--------|------|------|-------------|
118+| `gordon.events.processed` | Counter | - | Events handled successfully |
119+| `gordon.events.dropped` | Counter | - | Events dropped (channel full) |
120+
121+Attributes: `event_type`
122+
123+### HTTP (via otelhttp)
124+
125+The `otelhttp` middleware automatically records standard HTTP server metrics on both the proxy and registry servers:
126+
127+- `http.server.request.duration`
128+- `http.server.request.body.size`
129+- `http.server.response.body.size`
130+
131+## Trace Spans
132+
133+| Span Name | Package | Description |
134+|-----------|---------|-------------|
135+| `container.deploy` | container | Full deploy lifecycle (root span) |
136+| `container.create_and_start` | container | Container creation, start, and readiness |
137+| `container.ensure_image` | container | Image pull and tagging |
138+| `registry.put_manifest` | registry | Manifest storage and event publish |
139+| `proxy.get_target` | proxy | Proxy target resolution and caching |
140+
141+The `otelhttp` middleware adds an HTTP-level span to every request on both servers.
142+
143+## Examples
144+
145+### OpenObserve (self-hosted)
146+
147+```toml
148+[telemetry]
149+enabled = true
150+endpoint = "http://localhost:5080/api/default"
151+auth_token = "YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0"
152+traces = true
153+metrics = true
154+logs = true
155+trace_sample_rate = 1.0
156+```
157+
158+### Grafana Cloud (OTLP)
159+
160+```toml
161+[telemetry]
162+enabled = true
163+endpoint = "https://otlp-gateway-prod-us-east-0.grafana.net/otlp"
164+auth_token = "<instance-id>:<api-key>"
165+traces = true
166+metrics = true
167+logs = true
168+trace_sample_rate = 0.1
169+```
170+
171+### Traces Only (minimal overhead)
172+
173+```toml
174+[telemetry]
175+enabled = true
176+endpoint = "http://localhost:4318"
177+traces = true
178+metrics = false
179+logs = false
180+trace_sample_rate = 0.5
181+```
182+
183+## Environment Variable Override
184+
185+```bash
186+GORDON_TELEMETRY_ENABLED=true
187+GORDON_TELEMETRY_ENDPOINT=http://localhost:5080/api/default
188+GORDON_TELEMETRY_AUTH_TOKEN=YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0
189+GORDON_TELEMETRY_TRACES=true
190+GORDON_TELEMETRY_METRICS=true
191+GORDON_TELEMETRY_LOGS=true
192+GORDON_TELEMETRY_TRACE_SAMPLE_RATE=1.0
193+```
194+
195+## Related
196+
197+- [Configuration Overview](./index.md)
198+- [Logging](./logging.md)
199+- [Configuration Reference](./reference.md)
M docs/reference/index.md +1 -0
@@ -6,6 +6,7 @@ Technical reference documentation for Gordon.
66
77 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
88 - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
9+- [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
910 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
1011
1112 ## Related
@@ -6,6 +6,7 @@ Technical reference documentation for Gordon.
6 6
7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon7 - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon
8 - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation8 - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation
9+- [Telemetry & Metrics](../config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans
9 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions10 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
10 11
11 ## Related12 ## Related
M go.mod +20 -6
@@ -14,6 +14,11 @@ require (
1414 github.com/spf13/cobra v1.10.2
1515 github.com/spf13/viper v1.21.0
1616 github.com/stretchr/testify v1.11.1
17+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
18+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0
19+ go.opentelemetry.io/otel/sdk v1.40.0
20+ go.opentelemetry.io/otel/sdk/log v0.16.0
21+ go.opentelemetry.io/otel/sdk/metric v1.40.0
1722 golang.org/x/crypto v0.47.0
1823 golang.org/x/mod v0.32.0
1924 golang.org/x/term v0.39.0
@@ -23,6 +28,7 @@ require (
2328 require (
2429 github.com/atotto/clipboard v0.1.4 // indirect
2530 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
31+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
2632 github.com/cespare/xxhash/v2 v2.3.0 // indirect
2733 github.com/charmbracelet/colorprofile v0.4.1 // indirect
2834 github.com/charmbracelet/x/ansi v0.11.5 // indirect
@@ -32,6 +38,7 @@ require (
3238 github.com/clipperhouse/stringish v0.1.1 // indirect
3339 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
3440 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
41+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
3542 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3643 github.com/mattn/go-localereader v0.0.1 // indirect
3744 github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -41,7 +48,15 @@ require (
4148 github.com/rivo/uniseg v0.4.7 // indirect
4249 github.com/rs/zerolog v1.34.0 // indirect
4350 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
51+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
52+ go.opentelemetry.io/otel/log v0.16.0 // indirect
53+ go.opentelemetry.io/proto/otlp v1.9.0 // indirect
4454 go.yaml.in/yaml/v3 v3.0.4 // indirect
55+ golang.org/x/net v0.49.0 // indirect
56+ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
57+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
58+ google.golang.org/grpc v1.78.0 // indirect
59+ google.golang.org/protobuf v1.36.11 // indirect
4560 )
4661
4762 require (
@@ -81,12 +96,11 @@ require (
8196 github.com/spf13/pflag v1.0.10 // indirect
8297 github.com/subosito/gotenv v1.6.0 // indirect
8398 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
84- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
85- go.opentelemetry.io/otel v1.39.0 // indirect
86- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
87- go.opentelemetry.io/otel/metric v1.39.0 // indirect
88- go.opentelemetry.io/otel/sdk v1.36.0 // indirect
89- go.opentelemetry.io/otel/trace v1.39.0 // indirect
99+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
100+ go.opentelemetry.io/otel v1.40.0
101+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0
102+ go.opentelemetry.io/otel/metric v1.40.0
103+ go.opentelemetry.io/otel/trace v1.40.0
90104 golang.org/x/sys v0.40.0 // indirect
91105 golang.org/x/text v0.33.0 // indirect
92106 gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -14,6 +14,11 @@ require (
14 github.com/spf13/cobra v1.10.214 github.com/spf13/cobra v1.10.2
15 github.com/spf13/viper v1.21.015 github.com/spf13/viper v1.21.0
16 github.com/stretchr/testify v1.11.116 github.com/stretchr/testify v1.11.1
17+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
18+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0
19+ go.opentelemetry.io/otel/sdk v1.40.0
20+ go.opentelemetry.io/otel/sdk/log v0.16.0
21+ go.opentelemetry.io/otel/sdk/metric v1.40.0
17 golang.org/x/crypto v0.47.022 golang.org/x/crypto v0.47.0
18 golang.org/x/mod v0.32.023 golang.org/x/mod v0.32.0
19 golang.org/x/term v0.39.024 golang.org/x/term v0.39.0
@@ -23,6 +28,7 @@ require (
23 require (28 require (
24 github.com/atotto/clipboard v0.1.4 // indirect29 github.com/atotto/clipboard v0.1.4 // indirect
25 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect30 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
31+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
26 github.com/cespare/xxhash/v2 v2.3.0 // indirect32 github.com/cespare/xxhash/v2 v2.3.0 // indirect
27 github.com/charmbracelet/colorprofile v0.4.1 // indirect33 github.com/charmbracelet/colorprofile v0.4.1 // indirect
28 github.com/charmbracelet/x/ansi v0.11.5 // indirect34 github.com/charmbracelet/x/ansi v0.11.5 // indirect
@@ -32,6 +38,7 @@ require (
32 github.com/clipperhouse/stringish v0.1.1 // indirect38 github.com/clipperhouse/stringish v0.1.1 // indirect
33 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect39 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
34 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect40 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
41+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
35 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect42 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
36 github.com/mattn/go-localereader v0.0.1 // indirect43 github.com/mattn/go-localereader v0.0.1 // indirect
37 github.com/mattn/go-runewidth v0.0.19 // indirect44 github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -41,7 +48,15 @@ require (
41 github.com/rivo/uniseg v0.4.7 // indirect48 github.com/rivo/uniseg v0.4.7 // indirect
42 github.com/rs/zerolog v1.34.0 // indirect49 github.com/rs/zerolog v1.34.0 // indirect
43 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect50 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
51+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
52+ go.opentelemetry.io/otel/log v0.16.0 // indirect
53+ go.opentelemetry.io/proto/otlp v1.9.0 // indirect
44 go.yaml.in/yaml/v3 v3.0.4 // indirect54 go.yaml.in/yaml/v3 v3.0.4 // indirect
55+ golang.org/x/net v0.49.0 // indirect
56+ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
57+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
58+ google.golang.org/grpc v1.78.0 // indirect
59+ google.golang.org/protobuf v1.36.11 // indirect
45 )60 )
46 61
47 require (62 require (
@@ -81,12 +96,11 @@ require (
81 github.com/spf13/pflag v1.0.10 // indirect96 github.com/spf13/pflag v1.0.10 // indirect
82 github.com/subosito/gotenv v1.6.0 // indirect97 github.com/subosito/gotenv v1.6.0 // indirect
83 go.opentelemetry.io/auto/sdk v1.2.1 // indirect98 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
84- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect99+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
85- go.opentelemetry.io/otel v1.39.0 // indirect100+ go.opentelemetry.io/otel v1.40.0
86- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect101+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0
87- go.opentelemetry.io/otel/metric v1.39.0 // indirect102+ go.opentelemetry.io/otel/metric v1.40.0
88- go.opentelemetry.io/otel/sdk v1.36.0 // indirect103+ go.opentelemetry.io/otel/trace v1.40.0
89- go.opentelemetry.io/otel/trace v1.39.0 // indirect
90 golang.org/x/sys v0.40.0 // indirect104 golang.org/x/sys v0.40.0 // indirect
91 golang.org/x/text v0.33.0 // indirect105 golang.org/x/text v0.33.0 // indirect
92 gopkg.in/yaml.v3 v3.0.1 // indirect106 gopkg.in/yaml.v3 v3.0.1 // indirect
M go.sum +48 -30
@@ -10,8 +10,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
1010 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
1111 github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU=
1212 github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s=
13-github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
14-github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
13+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
14+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
1515 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
1616 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
1717 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
@@ -72,12 +72,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
7272 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
7373 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
7474 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
75+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
76+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
7577 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
7678 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7779 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7880 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
79-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
80-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
81+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
82+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
8183 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
8284 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
8385 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -161,22 +163,36 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
161163 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
162164 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
163165 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
164-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
165-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
166-go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
167-go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
168-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
169-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
170-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
171-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
172-go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
173-go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
174-go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
175-go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
176-go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
177-go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
178-go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
179-go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
166+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
167+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
168+go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
169+go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
170+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
171+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
172+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
173+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
174+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
175+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
176+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
177+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
178+go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
179+go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
180+go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
181+go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
182+go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
183+go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
184+go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
185+go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
186+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
187+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
188+go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
189+go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
190+go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
191+go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
192+go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
193+go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
194+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
195+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
180196 go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
181197 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
182198 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -187,8 +203,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
187203 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
188204 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
189205 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
190-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
191-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
206+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
207+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
192208 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
193209 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
194210 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -203,14 +219,16 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
203219 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
204220 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
205221 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
206-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
207-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
208-google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
209-google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
210-google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
211-google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
212-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
213-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
222+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
223+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
224+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
225+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
226+google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
227+google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
228+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
229+google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
230+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
231+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
214232 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
215233 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
216234 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -10,8 +10,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
10 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=10 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
11 github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU=11 github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU=
12 github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s=12 github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s=
13-github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=13+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
14-github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=14+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
15 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=15 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
16 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=16 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
17 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=17 github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
@@ -72,12 +72,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
72 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=72 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
73 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=73 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
74 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=74 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
75+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
76+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
75 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=77 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
76 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=78 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
77 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=79 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
78 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=80 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
79-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=81+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
80-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=82+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
81 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=83 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
82 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=84 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
83 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=85 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -161,22 +163,36 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
161 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=163 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
162 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=164 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
163 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=165 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
164-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=166+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
165-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=167+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
166-go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=168+go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
167-go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=169+go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
168-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=170+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
169-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=171+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
170-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=172+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
171-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=173+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
172-go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=174+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
173-go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=175+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
174-go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=176+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
175-go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=177+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
176-go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=178+go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
177-go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=179+go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
178-go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=180+go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
179-go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=181+go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
182+go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
183+go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
184+go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
185+go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
186+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
187+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
188+go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
189+go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
190+go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
191+go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
192+go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
193+go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
194+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
195+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
180 go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=196 go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
181 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=197 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
182 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=198 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -187,8 +203,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
187 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=203 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
188 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=204 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
189 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=205 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
190-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=206+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
191-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=207+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
192 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=208 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
193 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=209 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
194 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=210 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -203,14 +219,16 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
203 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=219 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
204 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=220 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
205 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=221 golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
206-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=222+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
207-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=223+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
208-google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=224+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
209-google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=225+google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
210-google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=226+google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
211-google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=227+google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
212-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=228+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
213-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=229+google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
230+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
231+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
214 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=232 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
215 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=233 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
216 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=234 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
M internal/adapters/in/http/middleware/logging.go +15 -4
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/zerowrap"
15+ "go.opentelemetry.io/otel/trace"
1516
1617 "github.com/bnema/gordon/internal/adapters/dto"
1718 )
@@ -95,8 +96,18 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
9596 // IP spoofing via X-Forwarded-For from untrusted sources.
9697 clientIP := GetClientIP(r, nets)
9798
98- // Attach the logger to the request context for downstream handlers
99- ctx := zerowrap.WithCtx(r.Context(), log)
99+ // Attach the logger to the request context for downstream handlers.
100+ // If an OTel span is active (from otelhttp middleware), inject
101+ // trace_id and span_id into the logger for log↔trace correlation.
102+ reqLog := log
103+ spanCtx := trace.SpanFromContext(r.Context()).SpanContext()
104+ if spanCtx.HasTraceID() {
105+ reqLog = reqLog.WithFields(map[string]any{
106+ "trace_id": spanCtx.TraceID().String(),
107+ "span_id": spanCtx.SpanID().String(),
108+ })
109+ }
110+ ctx := zerowrap.WithCtx(r.Context(), reqLog)
100111 r = r.WithContext(ctx)
101112
102113 // Call the next handler
@@ -105,8 +116,8 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
105116 // Calculate duration
106117 duration := time.Since(start)
107118
108- // Log the request
109- log.Info().
119+ // Log the request (use reqLog to include trace_id/span_id)
120+ reqLog.Info().
110121 Str(zerowrap.FieldLayer, "adapter").
111122 Str(zerowrap.FieldAdapter, "http").
112123 Str("request_id", requestID).
@@ -12,6 +12,7 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
15+ "go.opentelemetry.io/otel/trace"
15 16
16 "github.com/bnema/gordon/internal/adapters/dto"17 "github.com/bnema/gordon/internal/adapters/dto"
17 )18 )
@@ -95,8 +96,18 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
95 // IP spoofing via X-Forwarded-For from untrusted sources.96 // IP spoofing via X-Forwarded-For from untrusted sources.
96 clientIP := GetClientIP(r, nets)97 clientIP := GetClientIP(r, nets)
97 98
98- // Attach the logger to the request context for downstream handlers99+ // Attach the logger to the request context for downstream handlers.
99- ctx := zerowrap.WithCtx(r.Context(), log)100+ // If an OTel span is active (from otelhttp middleware), inject
101+ // trace_id and span_id into the logger for log↔trace correlation.
102+ reqLog := log
103+ spanCtx := trace.SpanFromContext(r.Context()).SpanContext()
104+ if spanCtx.HasTraceID() {
105+ reqLog = reqLog.WithFields(map[string]any{
106+ "trace_id": spanCtx.TraceID().String(),
107+ "span_id": spanCtx.SpanID().String(),
108+ })
109+ }
110+ ctx := zerowrap.WithCtx(r.Context(), reqLog)
100 r = r.WithContext(ctx)111 r = r.WithContext(ctx)
101 112
102 // Call the next handler113 // Call the next handler
@@ -105,8 +116,8 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
105 // Calculate duration116 // Calculate duration
106 duration := time.Since(start)117 duration := time.Since(start)
107 118
108- // Log the request119+ // Log the request (use reqLog to include trace_id/span_id)
109- log.Info().120+ reqLog.Info().
110 Str(zerowrap.FieldLayer, "adapter").121 Str(zerowrap.FieldLayer, "adapter").
111 Str(zerowrap.FieldAdapter, "http").122 Str(zerowrap.FieldAdapter, "http").
112 Str("request_id", requestID).123 Str("request_id", requestID).
M internal/adapters/out/eventbus/inmemory.go +26 -0
@@ -9,7 +9,10 @@ import (
99
1010 "github.com/bnema/zerowrap"
1111 "github.com/google/uuid"
12+ "go.opentelemetry.io/otel/attribute"
13+ "go.opentelemetry.io/otel/metric"
1214
15+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
1316 "github.com/bnema/gordon/internal/boundaries/out"
1417 "github.com/bnema/gordon/internal/domain"
1518 )
@@ -24,6 +27,15 @@ type InMemory struct {
2427 cancel context.CancelFunc
2528 bufferSize int
2629 log zerowrap.Logger
30+ metrics *telemetry.Metrics
31+}
32+
33+// SetMetrics sets the telemetry metrics for the event bus.
34+// Must be called before Start() to avoid data races on bus.metrics reads.
35+func (bus *InMemory) SetMetrics(m *telemetry.Metrics) {
36+ bus.mu.Lock()
37+ bus.metrics = m
38+ bus.mu.Unlock()
2739 }
2840
2941 // NewInMemory creates a new in-memory event bus.
@@ -83,6 +95,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
8395 Str("image_name", event.ImageName).
8496 Str("tag", event.Tag).
8597 Msg("event channel is full, dropping event after 5s timeout")
98+
99+ // Record dropped event metric
100+ if bus.metrics != nil {
101+ bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes(
102+ attribute.String("event_type", string(event.Type)),
103+ ))
104+ }
86105 return fmt.Errorf("event channel is full, dropping event %s", event.ID)
87106 }
88107 }
@@ -217,6 +236,13 @@ func (bus *InMemory) handleEvent(event domain.Event) {
217236 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
218237 Dur(zerowrap.FieldDuration, time.Since(start)).
219238 Msg("event handled successfully")
239+
240+ // Record processed event metric
241+ if bus.metrics != nil {
242+ bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes(
243+ attribute.String("event_type", string(event.Type)),
244+ ))
245+ }
220246 }
221247 case <-ctx.Done():
222248 cancel()
@@ -9,7 +9,10 @@ import (
9 9
10 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
11 "github.com/google/uuid"11 "github.com/google/uuid"
12+ "go.opentelemetry.io/otel/attribute"
13+ "go.opentelemetry.io/otel/metric"
12 14
15+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
13 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
15 )18 )
@@ -24,6 +27,15 @@ type InMemory struct {
24 cancel context.CancelFunc27 cancel context.CancelFunc
25 bufferSize int28 bufferSize int
26 log zerowrap.Logger29 log zerowrap.Logger
30+ metrics *telemetry.Metrics
31+}
32+
33+// SetMetrics sets the telemetry metrics for the event bus.
34+// Must be called before Start() to avoid data races on bus.metrics reads.
35+func (bus *InMemory) SetMetrics(m *telemetry.Metrics) {
36+ bus.mu.Lock()
37+ bus.metrics = m
38+ bus.mu.Unlock()
27 }39 }
28 40
29 // NewInMemory creates a new in-memory event bus.41 // NewInMemory creates a new in-memory event bus.
@@ -83,6 +95,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
83 Str("image_name", event.ImageName).95 Str("image_name", event.ImageName).
84 Str("tag", event.Tag).96 Str("tag", event.Tag).
85 Msg("event channel is full, dropping event after 5s timeout")97 Msg("event channel is full, dropping event after 5s timeout")
98+
99+ // Record dropped event metric
100+ if bus.metrics != nil {
101+ bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes(
102+ attribute.String("event_type", string(event.Type)),
103+ ))
104+ }
86 return fmt.Errorf("event channel is full, dropping event %s", event.ID)105 return fmt.Errorf("event channel is full, dropping event %s", event.ID)
87 }106 }
88 }107 }
@@ -217,6 +236,13 @@ func (bus *InMemory) handleEvent(event domain.Event) {
217 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).236 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)).
218 Dur(zerowrap.FieldDuration, time.Since(start)).237 Dur(zerowrap.FieldDuration, time.Since(start)).
219 Msg("event handled successfully")238 Msg("event handled successfully")
239+
240+ // Record processed event metric
241+ if bus.metrics != nil {
242+ bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes(
243+ attribute.String("event_type", string(event.Type)),
244+ ))
245+ }
220 }246 }
221 case <-ctx.Done():247 case <-ctx.Done():
222 cancel()248 cancel()
A internal/adapters/out/telemetry/metrics.go +82 -0
@@ -0,0 +1,82 @@
1+package telemetry
2+
3+import (
4+ "go.opentelemetry.io/otel"
5+ "go.opentelemetry.io/otel/metric"
6+)
7+
8+// Metrics holds Gordon-specific OTel metrics instruments.
9+type Metrics struct {
10+ // Deployments
11+ DeployTotal metric.Int64Counter
12+ DeployDuration metric.Float64Histogram
13+ DeployErrors metric.Int64Counter
14+
15+ // Container lifecycle
16+ ContainerRestarts metric.Int64Counter
17+ ContainerCrashLoops metric.Int64Counter
18+ ManagedContainers metric.Int64UpDownCounter
19+
20+ // Registry
21+ ImagePushTotal metric.Int64Counter
22+ ImagePushSize metric.Int64Counter // bytes
23+
24+ // Events
25+ EventsProcessed metric.Int64Counter
26+ EventsDropped metric.Int64Counter
27+}
28+
29+// NewMetrics creates and registers all Gordon metric instruments.
30+// Returns a noop-safe Metrics struct — all fields are always initialized
31+// (OTel returns noop instruments when no MeterProvider is set).
32+func NewMetrics() (*Metrics, error) {
33+ meter := otel.Meter("gordon")
34+ m := &Metrics{}
35+ var err error
36+
37+ if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
38+ metric.WithDescription("Total number of deployments")); err != nil {
39+ return nil, err
40+ }
41+ if m.DeployDuration, err = meter.Float64Histogram("gordon.deploy.duration_seconds",
42+ metric.WithDescription("Deploy duration in seconds"),
43+ metric.WithUnit("s"),
44+ metric.WithExplicitBucketBoundaries(1, 5, 10, 30, 60, 120, 300)); err != nil {
45+ return nil, err
46+ }
47+ if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors",
48+ metric.WithDescription("Total deploy errors")); err != nil {
49+ return nil, err
50+ }
51+ if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts",
52+ metric.WithDescription("Total container restarts")); err != nil {
53+ return nil, err
54+ }
55+ if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops",
56+ metric.WithDescription("Total crash loop detections")); err != nil {
57+ return nil, err
58+ }
59+ if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed",
60+ metric.WithDescription("Currently managed containers")); err != nil {
61+ return nil, err
62+ }
63+ if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total",
64+ metric.WithDescription("Total image pushes")); err != nil {
65+ return nil, err
66+ }
67+ if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes",
68+ metric.WithDescription("Total bytes pushed to registry"),
69+ metric.WithUnit("By")); err != nil {
70+ return nil, err
71+ }
72+ if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed",
73+ metric.WithDescription("Total events processed")); err != nil {
74+ return nil, err
75+ }
76+ if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped",
77+ metric.WithDescription("Total events dropped")); err != nil {
78+ return nil, err
79+ }
80+
81+ return m, nil
82+}
@@ -0,0 +1,82 @@
1+package telemetry
2+
3+import (
4+ "go.opentelemetry.io/otel"
5+ "go.opentelemetry.io/otel/metric"
6+)
7+
8+// Metrics holds Gordon-specific OTel metrics instruments.
9+type Metrics struct {
10+ // Deployments
11+ DeployTotal metric.Int64Counter
12+ DeployDuration metric.Float64Histogram
13+ DeployErrors metric.Int64Counter
14+
15+ // Container lifecycle
16+ ContainerRestarts metric.Int64Counter
17+ ContainerCrashLoops metric.Int64Counter
18+ ManagedContainers metric.Int64UpDownCounter
19+
20+ // Registry
21+ ImagePushTotal metric.Int64Counter
22+ ImagePushSize metric.Int64Counter // bytes
23+
24+ // Events
25+ EventsProcessed metric.Int64Counter
26+ EventsDropped metric.Int64Counter
27+}
28+
29+// NewMetrics creates and registers all Gordon metric instruments.
30+// Returns a noop-safe Metrics struct — all fields are always initialized
31+// (OTel returns noop instruments when no MeterProvider is set).
32+func NewMetrics() (*Metrics, error) {
33+ meter := otel.Meter("gordon")
34+ m := &Metrics{}
35+ var err error
36+
37+ if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
38+ metric.WithDescription("Total number of deployments")); err != nil {
39+ return nil, err
40+ }
41+ if m.DeployDuration, err = meter.Float64Histogram("gordon.deploy.duration_seconds",
42+ metric.WithDescription("Deploy duration in seconds"),
43+ metric.WithUnit("s"),
44+ metric.WithExplicitBucketBoundaries(1, 5, 10, 30, 60, 120, 300)); err != nil {
45+ return nil, err
46+ }
47+ if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors",
48+ metric.WithDescription("Total deploy errors")); err != nil {
49+ return nil, err
50+ }
51+ if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts",
52+ metric.WithDescription("Total container restarts")); err != nil {
53+ return nil, err
54+ }
55+ if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops",
56+ metric.WithDescription("Total crash loop detections")); err != nil {
57+ return nil, err
58+ }
59+ if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed",
60+ metric.WithDescription("Currently managed containers")); err != nil {
61+ return nil, err
62+ }
63+ if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total",
64+ metric.WithDescription("Total image pushes")); err != nil {
65+ return nil, err
66+ }
67+ if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes",
68+ metric.WithDescription("Total bytes pushed to registry"),
69+ metric.WithUnit("By")); err != nil {
70+ return nil, err
71+ }
72+ if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed",
73+ metric.WithDescription("Total events processed")); err != nil {
74+ return nil, err
75+ }
76+ if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped",
77+ metric.WithDescription("Total events dropped")); err != nil {
78+ return nil, err
79+ }
80+
81+ return m, nil
82+}
A internal/adapters/out/telemetry/provider.go +222 -0
@@ -0,0 +1,222 @@
1+// Package telemetry provides OpenTelemetry initialization for Gordon.
2+// It configures trace, metric, and log providers that export via OTLP/HTTP.
3+package telemetry
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/url"
9+ "strings"
10+
11+ "go.opentelemetry.io/otel"
12+ "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
13+ "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
14+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
15+ otellog "go.opentelemetry.io/otel/sdk/log"
16+ "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/resource"
18+ "go.opentelemetry.io/otel/sdk/trace"
19+ semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
20+)
21+
22+// Config holds telemetry configuration.
23+type Config struct {
24+ Enabled bool `mapstructure:"enabled"`
25+ Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318"
26+ AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass)
27+ Traces bool `mapstructure:"traces"` // Enable trace export
28+ Metrics bool `mapstructure:"metrics"` // Enable metric export
29+ Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP
30+ TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample
31+}
32+
33+// Provider holds the initialized OTel providers.
34+type Provider struct {
35+ TracerProvider *trace.TracerProvider
36+ MeterProvider *metric.MeterProvider
37+ LogProvider *otellog.LoggerProvider
38+}
39+
40+// endpointConfig holds parsed endpoint details shared across exporter setup.
41+type endpointConfig struct {
42+ host string
43+ basePath string
44+ insecure bool
45+ headers map[string]string
46+}
47+
48+// NewProvider creates and configures OTel providers based on the given config.
49+// Returns a noop provider if telemetry is disabled.
50+// The returned shutdown function must be called on application exit.
51+func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) {
52+ noop := func(context.Context) {}
53+
54+ if !cfg.Enabled || cfg.Endpoint == "" {
55+ return &Provider{}, noop, nil
56+ }
57+
58+ res, err := resource.New(ctx,
59+ resource.WithAttributes(
60+ semconv.ServiceName(serviceName),
61+ semconv.ServiceVersion(version),
62+ ),
63+ resource.WithOS(),
64+ resource.WithHost(),
65+ )
66+ if err != nil {
67+ return nil, noop, fmt.Errorf("create resource: %w", err)
68+ }
69+
70+ ep, err := parseEndpoint(cfg)
71+ if err != nil {
72+ return nil, noop, err
73+ }
74+
75+ var shutdowns []func(context.Context) error
76+ p := &Provider{}
77+
78+ if cfg.Traces {
79+ if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil {
80+ return nil, noop, err
81+ }
82+ }
83+
84+ if cfg.Metrics {
85+ if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil {
86+ return nil, noop, err
87+ }
88+ }
89+
90+ if cfg.Logs {
91+ if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil {
92+ return nil, noop, err
93+ }
94+ }
95+
96+ shutdown := func(ctx context.Context) {
97+ for _, fn := range shutdowns {
98+ _ = fn(ctx)
99+ }
100+ }
101+
102+ return p, shutdown, nil
103+}
104+
105+// parseEndpoint extracts host, path, and scheme from the configured endpoint URL.
106+func parseEndpoint(cfg Config) (*endpointConfig, error) {
107+ parsedURL, err := url.Parse(cfg.Endpoint)
108+ if err != nil {
109+ return nil, fmt.Errorf("parse endpoint URL: %w", err)
110+ }
111+
112+ headers := make(map[string]string)
113+ if cfg.AuthToken != "" {
114+ headers["Authorization"] = "Basic " + cfg.AuthToken
115+ }
116+
117+ return &endpointConfig{
118+ host: parsedURL.Host,
119+ basePath: strings.TrimSuffix(parsedURL.Path, "/"),
120+ insecure: parsedURL.Scheme == "http",
121+ headers: headers,
122+ }, nil
123+}
124+
125+// setupTracing initializes the trace provider with an OTLP/HTTP exporter.
126+func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
127+ traceOpts := []otlptracehttp.Option{
128+ otlptracehttp.WithEndpoint(ep.host),
129+ otlptracehttp.WithHeaders(ep.headers),
130+ }
131+ if ep.basePath != "" {
132+ traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces"))
133+ }
134+ if ep.insecure {
135+ traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
136+ }
137+
138+ traceExp, err := otlptracehttp.New(ctx, traceOpts...)
139+ if err != nil {
140+ return fmt.Errorf("create trace exporter: %w", err)
141+ }
142+
143+ // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample.
144+ // Clamp to [0, 1] so out-of-range values don't produce surprising behavior.
145+ rate := cfg.TraceSampleRate
146+ if rate < 0 {
147+ rate = 0
148+ } else if rate > 1 {
149+ rate = 1
150+ }
151+ sampler := trace.AlwaysSample()
152+ if rate == 0 {
153+ sampler = trace.NeverSample()
154+ } else if rate < 1.0 {
155+ sampler = trace.TraceIDRatioBased(rate)
156+ }
157+
158+ tp := trace.NewTracerProvider(
159+ trace.WithBatcher(traceExp),
160+ trace.WithResource(res),
161+ trace.WithSampler(sampler),
162+ )
163+ otel.SetTracerProvider(tp)
164+ p.TracerProvider = tp
165+ *shutdowns = append(*shutdowns, tp.Shutdown)
166+ return nil
167+}
168+
169+// setupMetrics initializes the meter provider with an OTLP/HTTP exporter.
170+func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
171+ metricOpts := []otlpmetrichttp.Option{
172+ otlpmetrichttp.WithEndpoint(ep.host),
173+ otlpmetrichttp.WithHeaders(ep.headers),
174+ }
175+ if ep.basePath != "" {
176+ metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics"))
177+ }
178+ if ep.insecure {
179+ metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure())
180+ }
181+
182+ metricExp, err := otlpmetrichttp.New(ctx, metricOpts...)
183+ if err != nil {
184+ return fmt.Errorf("create metric exporter: %w", err)
185+ }
186+
187+ mp := metric.NewMeterProvider(
188+ metric.WithReader(metric.NewPeriodicReader(metricExp)),
189+ metric.WithResource(res),
190+ )
191+ otel.SetMeterProvider(mp)
192+ p.MeterProvider = mp
193+ *shutdowns = append(*shutdowns, mp.Shutdown)
194+ return nil
195+}
196+
197+// setupLogging initializes the log provider with an OTLP/HTTP exporter.
198+func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
199+ logOpts := []otlploghttp.Option{
200+ otlploghttp.WithEndpoint(ep.host),
201+ otlploghttp.WithHeaders(ep.headers),
202+ }
203+ if ep.basePath != "" {
204+ logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs"))
205+ }
206+ if ep.insecure {
207+ logOpts = append(logOpts, otlploghttp.WithInsecure())
208+ }
209+
210+ logExp, err := otlploghttp.New(ctx, logOpts...)
211+ if err != nil {
212+ return fmt.Errorf("create log exporter: %w", err)
213+ }
214+
215+ lp := otellog.NewLoggerProvider(
216+ otellog.WithProcessor(otellog.NewBatchProcessor(logExp)),
217+ otellog.WithResource(res),
218+ )
219+ p.LogProvider = lp
220+ *shutdowns = append(*shutdowns, lp.Shutdown)
221+ return nil
222+}
@@ -0,0 +1,222 @@
1+// Package telemetry provides OpenTelemetry initialization for Gordon.
2+// It configures trace, metric, and log providers that export via OTLP/HTTP.
3+package telemetry
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/url"
9+ "strings"
10+
11+ "go.opentelemetry.io/otel"
12+ "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
13+ "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
14+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
15+ otellog "go.opentelemetry.io/otel/sdk/log"
16+ "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/resource"
18+ "go.opentelemetry.io/otel/sdk/trace"
19+ semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
20+)
21+
22+// Config holds telemetry configuration.
23+type Config struct {
24+ Enabled bool `mapstructure:"enabled"`
25+ Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318"
26+ AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass)
27+ Traces bool `mapstructure:"traces"` // Enable trace export
28+ Metrics bool `mapstructure:"metrics"` // Enable metric export
29+ Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP
30+ TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample
31+}
32+
33+// Provider holds the initialized OTel providers.
34+type Provider struct {
35+ TracerProvider *trace.TracerProvider
36+ MeterProvider *metric.MeterProvider
37+ LogProvider *otellog.LoggerProvider
38+}
39+
40+// endpointConfig holds parsed endpoint details shared across exporter setup.
41+type endpointConfig struct {
42+ host string
43+ basePath string
44+ insecure bool
45+ headers map[string]string
46+}
47+
48+// NewProvider creates and configures OTel providers based on the given config.
49+// Returns a noop provider if telemetry is disabled.
50+// The returned shutdown function must be called on application exit.
51+func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) {
52+ noop := func(context.Context) {}
53+
54+ if !cfg.Enabled || cfg.Endpoint == "" {
55+ return &Provider{}, noop, nil
56+ }
57+
58+ res, err := resource.New(ctx,
59+ resource.WithAttributes(
60+ semconv.ServiceName(serviceName),
61+ semconv.ServiceVersion(version),
62+ ),
63+ resource.WithOS(),
64+ resource.WithHost(),
65+ )
66+ if err != nil {
67+ return nil, noop, fmt.Errorf("create resource: %w", err)
68+ }
69+
70+ ep, err := parseEndpoint(cfg)
71+ if err != nil {
72+ return nil, noop, err
73+ }
74+
75+ var shutdowns []func(context.Context) error
76+ p := &Provider{}
77+
78+ if cfg.Traces {
79+ if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil {
80+ return nil, noop, err
81+ }
82+ }
83+
84+ if cfg.Metrics {
85+ if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil {
86+ return nil, noop, err
87+ }
88+ }
89+
90+ if cfg.Logs {
91+ if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil {
92+ return nil, noop, err
93+ }
94+ }
95+
96+ shutdown := func(ctx context.Context) {
97+ for _, fn := range shutdowns {
98+ _ = fn(ctx)
99+ }
100+ }
101+
102+ return p, shutdown, nil
103+}
104+
105+// parseEndpoint extracts host, path, and scheme from the configured endpoint URL.
106+func parseEndpoint(cfg Config) (*endpointConfig, error) {
107+ parsedURL, err := url.Parse(cfg.Endpoint)
108+ if err != nil {
109+ return nil, fmt.Errorf("parse endpoint URL: %w", err)
110+ }
111+
112+ headers := make(map[string]string)
113+ if cfg.AuthToken != "" {
114+ headers["Authorization"] = "Basic " + cfg.AuthToken
115+ }
116+
117+ return &endpointConfig{
118+ host: parsedURL.Host,
119+ basePath: strings.TrimSuffix(parsedURL.Path, "/"),
120+ insecure: parsedURL.Scheme == "http",
121+ headers: headers,
122+ }, nil
123+}
124+
125+// setupTracing initializes the trace provider with an OTLP/HTTP exporter.
126+func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
127+ traceOpts := []otlptracehttp.Option{
128+ otlptracehttp.WithEndpoint(ep.host),
129+ otlptracehttp.WithHeaders(ep.headers),
130+ }
131+ if ep.basePath != "" {
132+ traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces"))
133+ }
134+ if ep.insecure {
135+ traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
136+ }
137+
138+ traceExp, err := otlptracehttp.New(ctx, traceOpts...)
139+ if err != nil {
140+ return fmt.Errorf("create trace exporter: %w", err)
141+ }
142+
143+ // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample.
144+ // Clamp to [0, 1] so out-of-range values don't produce surprising behavior.
145+ rate := cfg.TraceSampleRate
146+ if rate < 0 {
147+ rate = 0
148+ } else if rate > 1 {
149+ rate = 1
150+ }
151+ sampler := trace.AlwaysSample()
152+ if rate == 0 {
153+ sampler = trace.NeverSample()
154+ } else if rate < 1.0 {
155+ sampler = trace.TraceIDRatioBased(rate)
156+ }
157+
158+ tp := trace.NewTracerProvider(
159+ trace.WithBatcher(traceExp),
160+ trace.WithResource(res),
161+ trace.WithSampler(sampler),
162+ )
163+ otel.SetTracerProvider(tp)
164+ p.TracerProvider = tp
165+ *shutdowns = append(*shutdowns, tp.Shutdown)
166+ return nil
167+}
168+
169+// setupMetrics initializes the meter provider with an OTLP/HTTP exporter.
170+func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
171+ metricOpts := []otlpmetrichttp.Option{
172+ otlpmetrichttp.WithEndpoint(ep.host),
173+ otlpmetrichttp.WithHeaders(ep.headers),
174+ }
175+ if ep.basePath != "" {
176+ metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics"))
177+ }
178+ if ep.insecure {
179+ metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure())
180+ }
181+
182+ metricExp, err := otlpmetrichttp.New(ctx, metricOpts...)
183+ if err != nil {
184+ return fmt.Errorf("create metric exporter: %w", err)
185+ }
186+
187+ mp := metric.NewMeterProvider(
188+ metric.WithReader(metric.NewPeriodicReader(metricExp)),
189+ metric.WithResource(res),
190+ )
191+ otel.SetMeterProvider(mp)
192+ p.MeterProvider = mp
193+ *shutdowns = append(*shutdowns, mp.Shutdown)
194+ return nil
195+}
196+
197+// setupLogging initializes the log provider with an OTLP/HTTP exporter.
198+func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error {
199+ logOpts := []otlploghttp.Option{
200+ otlploghttp.WithEndpoint(ep.host),
201+ otlploghttp.WithHeaders(ep.headers),
202+ }
203+ if ep.basePath != "" {
204+ logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs"))
205+ }
206+ if ep.insecure {
207+ logOpts = append(logOpts, otlploghttp.WithInsecure())
208+ }
209+
210+ logExp, err := otlploghttp.New(ctx, logOpts...)
211+ if err != nil {
212+ return fmt.Errorf("create log exporter: %w", err)
213+ }
214+
215+ lp := otellog.NewLoggerProvider(
216+ otellog.WithProcessor(otellog.NewBatchProcessor(logExp)),
217+ otellog.WithResource(res),
218+ )
219+ p.LogProvider = lp
220+ *shutdowns = append(*shutdowns, lp.Shutdown)
221+ return nil
222+}
M internal/app/run.go +76 -4
@@ -25,6 +25,7 @@ import (
2525 "time"
2626
2727 "github.com/bnema/zerowrap"
28+ zerowrapotel "github.com/bnema/zerowrap/otel"
2829 "github.com/fsnotify/fsnotify"
2930 "github.com/spf13/viper"
3031
@@ -38,8 +39,12 @@ import (
3839 "github.com/bnema/gordon/internal/adapters/out/logwriter"
3940 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
4041 "github.com/bnema/gordon/internal/adapters/out/secrets"
42+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
4143 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
4244
45+ // OTel
46+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
47+
4348 // Adapters - Input
4449 "github.com/bnema/gordon/internal/adapters/dto"
4550 "github.com/bnema/gordon/internal/adapters/in/http/admin"
@@ -53,6 +58,9 @@ import (
5358 // Domain
5459 "github.com/bnema/gordon/internal/domain"
5560
61+ // Packages
62+ "github.com/bnema/gordon/pkg/version"
63+
5664 // Use cases
5765 "github.com/bnema/gordon/internal/usecase/auth"
5866 "github.com/bnema/gordon/internal/usecase/backup"
@@ -153,6 +161,8 @@ type Config struct {
153161 KeepLast int `mapstructure:"keep_last"`
154162 } `mapstructure:"prune"`
155163 } `mapstructure:"images"`
164+
165+ Telemetry telemetry.Config `mapstructure:"telemetry"`
156166 }
157167
158168 // services holds all the services used by the application.
@@ -201,6 +211,29 @@ func Run(ctx context.Context, configPath string) error {
201211 }
202212
203213 ctx = zerowrap.WithCtx(ctx, log)
214+
215+ // Initialize OpenTelemetry
216+ telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
217+ if err != nil {
218+ log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
219+ } else {
220+ // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
221+ defer func() {
222+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
223+ defer cancel()
224+ telShutdown(shutdownCtx)
225+ }()
226+ if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
227+ // Bridge zerowrap logs to OTel if log export is enabled
228+ if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
229+ otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
230+ log = zerowrap.WithHook(log, otelHook)
231+ ctx = zerowrap.WithCtx(ctx, log)
232+ }
233+ log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
234+ }
235+ }
236+
204237 log.Info().Msg("Gordon starting")
205238
206239 if err := ensureTLSConfig(&cfg, log); err != nil {
@@ -470,6 +503,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
470503 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
471504 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
472505
506+ injectTelemetryMetrics(cfg, svc, log)
507+
473508 proxyCfg, err := buildProxyConfig(cfg, log)
474509 if err != nil {
475510 return nil, err
@@ -516,6 +551,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
516551 return svc, nil
517552 }
518553
554+// injectTelemetryMetrics creates and injects OTel metrics into services when
555+// telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
556+func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
557+ if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics {
558+ return
559+ }
560+ gordonMetrics, err := telemetry.NewMetrics()
561+ if err != nil {
562+ log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics")
563+ return
564+ }
565+ svc.containerSvc.SetMetrics(gordonMetrics)
566+ svc.registrySvc.SetMetrics(gordonMetrics)
567+ svc.eventBus.SetMetrics(gordonMetrics)
568+}
569+
519570 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
520571 var err error
521572 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
@@ -1328,7 +1379,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
13281379 middleware.SecurityHeaders,
13291380 }
13301381
1331- proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1382+ proxyWithMiddleware := otelhttp.NewHandler(
1383+ middleware.Chain(proxyMiddlewares...)(svc.proxySvc),
1384+ "gordon.proxy",
1385+ )
13321386 proxyMux := http.NewServeMux()
13331387 proxyMux.Handle("/", proxyWithMiddleware)
13341388
@@ -1360,7 +1414,11 @@ func buildRegistryHandlerWithMiddleware(
13601414
13611415 appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
13621416
1363- return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware
1417+ registryWithOtel := otelhttp.NewHandler(
1418+ middleware.Chain(registryMiddlewares...)(registryHandler),
1419+ "gordon.registry",
1420+ )
1421+ return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
13641422 }
13651423
13661424 func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
@@ -1457,7 +1515,10 @@ func registerAuthRoutes(
14571515 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
14581516 }
14591517 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1460- authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1518+ authWithMiddleware := otelhttp.NewHandler(
1519+ middleware.Chain(authMiddlewares...)(svc.authHandler),
1520+ "gordon.auth",
1521+ )
14611522 registryMux.Handle("/auth/", authWithMiddleware)
14621523 }
14631524
@@ -1502,7 +1563,10 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
15021563 })
15031564 }
15041565
1505- adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1566+ adminWithMiddleware := otelhttp.NewHandler(
1567+ middleware.Chain(adminMiddlewares...)(svc.adminHandler),
1568+ "gordon.admin",
1569+ )
15061570 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
15071571 proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))
15081572 }
@@ -2219,6 +2283,14 @@ func loadConfig(v *viper.Viper, configPath string) error {
22192283 v.SetDefault("images.prune.enabled", false)
22202284 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
22212285 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2286+ v.SetDefault("telemetry.enabled", false)
2287+ v.SetDefault("telemetry.endpoint", "")
2288+ v.SetDefault("telemetry.auth_token", "")
2289+ v.SetDefault("telemetry.traces", true)
2290+ v.SetDefault("telemetry.metrics", true)
2291+ v.SetDefault("telemetry.logs", true)
2292+ v.SetDefault("telemetry.trace_sample_rate", 1.0)
2293+
22222294 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
22232295 v.SetDefault("server.registry_allowed_ips", []string{})
22242296 v.SetDefault("deploy.readiness_delay", "5s")
@@ -25,6 +25,7 @@ import (
25 "time"25 "time"
26 26
27 "github.com/bnema/zerowrap"27 "github.com/bnema/zerowrap"
28+ zerowrapotel "github.com/bnema/zerowrap/otel"
28 "github.com/fsnotify/fsnotify"29 "github.com/fsnotify/fsnotify"
29 "github.com/spf13/viper"30 "github.com/spf13/viper"
30 31
@@ -38,8 +39,12 @@ import (
38 "github.com/bnema/gordon/internal/adapters/out/logwriter"39 "github.com/bnema/gordon/internal/adapters/out/logwriter"
39 "github.com/bnema/gordon/internal/adapters/out/ratelimit"40 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
40 "github.com/bnema/gordon/internal/adapters/out/secrets"41 "github.com/bnema/gordon/internal/adapters/out/secrets"
42+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"43 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42 44
45+ // OTel
46+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
47+
43 // Adapters - Input48 // Adapters - Input
44 "github.com/bnema/gordon/internal/adapters/dto"49 "github.com/bnema/gordon/internal/adapters/dto"
45 "github.com/bnema/gordon/internal/adapters/in/http/admin"50 "github.com/bnema/gordon/internal/adapters/in/http/admin"
@@ -53,6 +58,9 @@ import (
53 // Domain58 // Domain
54 "github.com/bnema/gordon/internal/domain"59 "github.com/bnema/gordon/internal/domain"
55 60
61+ // Packages
62+ "github.com/bnema/gordon/pkg/version"
63+
56 // Use cases64 // Use cases
57 "github.com/bnema/gordon/internal/usecase/auth"65 "github.com/bnema/gordon/internal/usecase/auth"
58 "github.com/bnema/gordon/internal/usecase/backup"66 "github.com/bnema/gordon/internal/usecase/backup"
@@ -153,6 +161,8 @@ type Config struct {
153 KeepLast int `mapstructure:"keep_last"`161 KeepLast int `mapstructure:"keep_last"`
154 } `mapstructure:"prune"`162 } `mapstructure:"prune"`
155 } `mapstructure:"images"`163 } `mapstructure:"images"`
164+
165+ Telemetry telemetry.Config `mapstructure:"telemetry"`
156 }166 }
157 167
158 // services holds all the services used by the application.168 // services holds all the services used by the application.
@@ -201,6 +211,29 @@ func Run(ctx context.Context, configPath string) error {
201 }211 }
202 212
203 ctx = zerowrap.WithCtx(ctx, log)213 ctx = zerowrap.WithCtx(ctx, log)
214+
215+ // Initialize OpenTelemetry
216+ telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
217+ if err != nil {
218+ log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
219+ } else {
220+ // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
221+ defer func() {
222+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
223+ defer cancel()
224+ telShutdown(shutdownCtx)
225+ }()
226+ if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
227+ // Bridge zerowrap logs to OTel if log export is enabled
228+ if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
229+ otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
230+ log = zerowrap.WithHook(log, otelHook)
231+ ctx = zerowrap.WithCtx(ctx, log)
232+ }
233+ log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
234+ }
235+ }
236+
204 log.Info().Msg("Gordon starting")237 log.Info().Msg("Gordon starting")
205 238
206 if err := ensureTLSConfig(&cfg, log); err != nil {239 if err := ensureTLSConfig(&cfg, log); err != nil {
@@ -470,6 +503,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
470 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)503 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
471 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)504 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
472 505
506+ injectTelemetryMetrics(cfg, svc, log)
507+
473 proxyCfg, err := buildProxyConfig(cfg, log)508 proxyCfg, err := buildProxyConfig(cfg, log)
474 if err != nil {509 if err != nil {
475 return nil, err510 return nil, err
@@ -516,6 +551,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
516 return svc, nil551 return svc, nil
517 }552 }
518 553
554+// injectTelemetryMetrics creates and injects OTel metrics into services when
555+// telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
556+func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
557+ if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics {
558+ return
559+ }
560+ gordonMetrics, err := telemetry.NewMetrics()
561+ if err != nil {
562+ log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics")
563+ return
564+ }
565+ svc.containerSvc.SetMetrics(gordonMetrics)
566+ svc.registrySvc.SetMetrics(gordonMetrics)
567+ svc.eventBus.SetMetrics(gordonMetrics)
568+}
569+
519 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {570 func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
520 var err error571 var err error
521 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()572 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
@@ -1328,7 +1379,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1328 middleware.SecurityHeaders,1379 middleware.SecurityHeaders,
1329 }1380 }
1330 1381
1331- proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)1382+ proxyWithMiddleware := otelhttp.NewHandler(
1383+ middleware.Chain(proxyMiddlewares...)(svc.proxySvc),
1384+ "gordon.proxy",
1385+ )
1332 proxyMux := http.NewServeMux()1386 proxyMux := http.NewServeMux()
1333 proxyMux.Handle("/", proxyWithMiddleware)1387 proxyMux.Handle("/", proxyWithMiddleware)
1334 1388
@@ -1360,7 +1414,11 @@ func buildRegistryHandlerWithMiddleware(
1360 1414
1361 appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)1415 appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
1362 1416
1363- return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware1417+ registryWithOtel := otelhttp.NewHandler(
1418+ middleware.Chain(registryMiddlewares...)(registryHandler),
1419+ "gordon.registry",
1420+ )
1421+ return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
1364 }1422 }
1365 1423
1366 func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {1424 func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
@@ -1457,7 +1515,10 @@ func registerAuthRoutes(
1457 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)1515 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1458 }1516 }
1459 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)1517 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1460- authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)1518+ authWithMiddleware := otelhttp.NewHandler(
1519+ middleware.Chain(authMiddlewares...)(svc.authHandler),
1520+ "gordon.auth",
1521+ )
1461 registryMux.Handle("/auth/", authWithMiddleware)1522 registryMux.Handle("/auth/", authWithMiddleware)
1462 }1523 }
1463 1524
@@ -1502,7 +1563,10 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
1502 })1563 })
1503 }1564 }
1504 1565
1505- adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)1566+ adminWithMiddleware := otelhttp.NewHandler(
1567+ middleware.Chain(adminMiddlewares...)(svc.adminHandler),
1568+ "gordon.admin",
1569+ )
1506 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))1570 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1507 proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))1571 proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))
1508 }1572 }
@@ -2219,6 +2283,14 @@ func loadConfig(v *viper.Viper, configPath string) error {
2219 v.SetDefault("images.prune.enabled", false)2283 v.SetDefault("images.prune.enabled", false)
2220 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))2284 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2221 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)2285 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2286+ v.SetDefault("telemetry.enabled", false)
2287+ v.SetDefault("telemetry.endpoint", "")
2288+ v.SetDefault("telemetry.auth_token", "")
2289+ v.SetDefault("telemetry.traces", true)
2290+ v.SetDefault("telemetry.metrics", true)
2291+ v.SetDefault("telemetry.logs", true)
2292+ v.SetDefault("telemetry.trace_sample_rate", 1.0)
2293+
2222 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit2294 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2223 v.SetDefault("server.registry_allowed_ips", []string{})2295 v.SetDefault("server.registry_allowed_ips", []string{})
2224 v.SetDefault("deploy.readiness_delay", "5s")2296 v.SetDefault("deploy.readiness_delay", "5s")
M internal/usecase/container/monitor.go +18 -0
@@ -6,6 +6,8 @@ import (
66 "time"
77
88 "github.com/bnema/zerowrap"
9+ "go.opentelemetry.io/otel/attribute"
10+ "go.opentelemetry.io/otel/metric"
911
1012 "github.com/bnema/gordon/internal/domain"
1113 )
@@ -199,6 +201,12 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa
199201 Int("consecutive", rec.consecutive).
200202 Dur("backoff", backoff).
201203 Msg("monitor: crash loop detected, backing off")
204+
205+ // Record crash loop metric
206+ if m.service.metrics != nil {
207+ attrs := metric.WithAttributes(attribute.String("domain", domainName))
208+ m.service.metrics.ContainerCrashLoops.Add(ctx, 1, attrs)
209+ }
202210 return
203211 }
204212 m.mu.Unlock()
@@ -218,6 +226,16 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa
218226
219227 if err := m.service.runtime.StartContainer(ctx, containerID); err != nil {
220228 log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container")
229+ return
230+ }
231+
232+ // Record restart metric only on successful restart
233+ if m.service.metrics != nil {
234+ attrs := metric.WithAttributes(
235+ attribute.String("domain", domainName),
236+ attribute.String("source", "monitor"),
237+ )
238+ m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs)
221239 }
222240 }
223241
@@ -6,6 +6,8 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
9+ "go.opentelemetry.io/otel/attribute"
10+ "go.opentelemetry.io/otel/metric"
9 11
10 "github.com/bnema/gordon/internal/domain"12 "github.com/bnema/gordon/internal/domain"
11 )13 )
@@ -199,6 +201,12 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa
199 Int("consecutive", rec.consecutive).201 Int("consecutive", rec.consecutive).
200 Dur("backoff", backoff).202 Dur("backoff", backoff).
201 Msg("monitor: crash loop detected, backing off")203 Msg("monitor: crash loop detected, backing off")
204+
205+ // Record crash loop metric
206+ if m.service.metrics != nil {
207+ attrs := metric.WithAttributes(attribute.String("domain", domainName))
208+ m.service.metrics.ContainerCrashLoops.Add(ctx, 1, attrs)
209+ }
202 return210 return
203 }211 }
204 m.mu.Unlock()212 m.mu.Unlock()
@@ -218,6 +226,16 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa
218 226
219 if err := m.service.runtime.StartContainer(ctx, containerID); err != nil {227 if err := m.service.runtime.StartContainer(ctx, containerID); err != nil {
220 log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container")228 log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container")
229+ return
230+ }
231+
232+ // Record restart metric only on successful restart
233+ if m.service.metrics != nil {
234+ attrs := metric.WithAttributes(
235+ attribute.String("domain", domainName),
236+ attribute.String("source", "monitor"),
237+ )
238+ m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs)
221 }239 }
222 }240 }
223 241
M internal/usecase/container/service.go +73 -0
@@ -12,7 +12,13 @@ import (
1212 "time"
1313
1414 "github.com/bnema/zerowrap"
15+ "go.opentelemetry.io/otel"
16+ "go.opentelemetry.io/otel/attribute"
17+ "go.opentelemetry.io/otel/codes"
18+ "go.opentelemetry.io/otel/metric"
19+ "go.opentelemetry.io/otel/trace"
1520
21+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
1622 "github.com/bnema/gordon/internal/boundaries/out"
1723 "github.com/bnema/gordon/internal/domain"
1824 )
@@ -44,6 +50,8 @@ type Config struct {
4450 DrainTimeout time.Duration // Max wait for in-flight requests to drain
4551 }
4652
53+var tracer = otel.Tracer("gordon.container")
54+
4755 const (
4856 PullPolicyAlways = "always"
4957 PullPolicyIfNotPresent = "if-not-present"
@@ -65,6 +73,7 @@ type Service struct {
6573 cacheInvalidator out.ProxyCacheInvalidator
6674 drainWaiter out.ProxyDrainWaiter
6775 config Config
76+ metrics *telemetry.Metrics
6877 containers map[string]*domain.Container
6978 attachments map[string][]string // ownerDomain → []containerIDs
7079 mu sync.RWMutex
@@ -135,6 +144,11 @@ func NewService(
135144 }
136145 }
137146
147+// SetMetrics sets the telemetry metrics for the container service.
148+func (s *Service) SetMetrics(m *telemetry.Metrics) {
149+ s.metrics = m
150+}
151+
138152 // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
139153 // cache invalidation during zero-downtime deployments. This must be called
140154 // after construction because the proxy service is created after the container
@@ -216,6 +230,15 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st
216230 // Deploy creates and starts a container for the given route.
217231 // Implements zero-downtime deployment: new container starts before old one stops.
218232 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
233+ ctx, span := tracer.Start(ctx, "container.deploy",
234+ trace.WithAttributes(
235+ attribute.String("domain", route.Domain),
236+ attribute.String("image", route.Image),
237+ ))
238+ defer span.End()
239+
240+ deployStart := time.Now()
241+
219242 // Serialize deploys for the same domain to prevent race conditions
220243 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
221244 unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
@@ -232,6 +255,26 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
232255 })
233256 log := zerowrap.FromCtx(ctx)
234257
258+ // Record deploy metrics and trace status
259+ defer func() {
260+ if err != nil {
261+ span.RecordError(err)
262+ span.SetStatus(codes.Error, err.Error())
263+ }
264+ if s.metrics == nil {
265+ return
266+ }
267+ attrs := metric.WithAttributes(
268+ attribute.String("domain", route.Domain),
269+ attribute.String("image", route.Image),
270+ )
271+ s.metrics.DeployTotal.Add(ctx, 1, attrs)
272+ s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs)
273+ if err != nil {
274+ s.metrics.DeployErrors.Add(ctx, 1, attrs)
275+ }
276+ }()
277+
235278 existing, hasExisting := s.getTrackedContainer(route.Domain)
236279
237280 resources, err := s.prepareDeployResources(ctx, route, existing)
@@ -369,6 +412,9 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
369412 }
370413
371414 func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
415+ ctx, span := tracer.Start(ctx, "container.create_and_start")
416+ defer span.End()
417+
372418 log := zerowrap.FromCtx(ctx)
373419
374420 containerConfig := s.buildContainerConfig(
@@ -408,9 +454,17 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
408454
409455 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
410456 s.mu.Lock()
457+ _, wasTracked := s.containers[domainName]
411458 s.containers[domainName] = container
412459 s.mu.Unlock()
413460
461+ // Track managed container count (only increment for new domains, not replacements)
462+ if !wasTracked && s.metrics != nil {
463+ s.metrics.ManagedContainers.Add(ctx, 1, metric.WithAttributes(
464+ attribute.String("domain", domainName),
465+ ))
466+ }
467+
414468 s.publishContainerDeployed(ctx, domainName, container.ID)
415469
416470 s.mu.RLock()
@@ -539,6 +593,14 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
539593 }
540594 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
541595
596+ // Record restart metric
597+ if s.metrics != nil {
598+ s.metrics.ContainerRestarts.Add(ctx, 1, metric.WithAttributes(
599+ attribute.String("domain", domainName),
600+ attribute.String("source", "api"),
601+ ))
602+ }
603+
542604 // Restart attachments if requested
543605 if withAttachments && len(attachmentIDs) > 0 {
544606 for _, attachID := range attachmentIDs {
@@ -626,6 +688,13 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
626688 }
627689 s.mu.Unlock()
628690
691+ // Decrement managed container count
692+ if removedDomain != "" && s.metrics != nil {
693+ s.metrics.ManagedContainers.Add(ctx, -1, metric.WithAttributes(
694+ attribute.String("domain", removedDomain),
695+ ))
696+ }
697+
629698 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
630699 // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
631700 // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
@@ -1161,6 +1230,10 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
11611230 // - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
11621231 // - For tagged images, returns the original imageRef after tagging the pulled image
11631232 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
1233+ ctx, span := tracer.Start(ctx, "container.ensure_image",
1234+ trace.WithAttributes(attribute.String("image", imageRef)))
1235+ defer span.End()
1236+
11641237 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
11651238 log := zerowrap.FromCtx(ctx)
11661239
@@ -12,7 +12,13 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
15+ "go.opentelemetry.io/otel"
16+ "go.opentelemetry.io/otel/attribute"
17+ "go.opentelemetry.io/otel/codes"
18+ "go.opentelemetry.io/otel/metric"
19+ "go.opentelemetry.io/otel/trace"
15 20
21+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
16 "github.com/bnema/gordon/internal/boundaries/out"22 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"23 "github.com/bnema/gordon/internal/domain"
18 )24 )
@@ -44,6 +50,8 @@ type Config struct {
44 DrainTimeout time.Duration // Max wait for in-flight requests to drain50 DrainTimeout time.Duration // Max wait for in-flight requests to drain
45 }51 }
46 52
53+var tracer = otel.Tracer("gordon.container")
54+
47 const (55 const (
48 PullPolicyAlways = "always"56 PullPolicyAlways = "always"
49 PullPolicyIfNotPresent = "if-not-present"57 PullPolicyIfNotPresent = "if-not-present"
@@ -65,6 +73,7 @@ type Service struct {
65 cacheInvalidator out.ProxyCacheInvalidator73 cacheInvalidator out.ProxyCacheInvalidator
66 drainWaiter out.ProxyDrainWaiter74 drainWaiter out.ProxyDrainWaiter
67 config Config75 config Config
76+ metrics *telemetry.Metrics
68 containers map[string]*domain.Container77 containers map[string]*domain.Container
69 attachments map[string][]string // ownerDomain → []containerIDs78 attachments map[string][]string // ownerDomain → []containerIDs
70 mu sync.RWMutex79 mu sync.RWMutex
@@ -135,6 +144,11 @@ func NewService(
135 }144 }
136 }145 }
137 146
147+// SetMetrics sets the telemetry metrics for the container service.
148+func (s *Service) SetMetrics(m *telemetry.Metrics) {
149+ s.metrics = m
150+}
151+
138 // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous152 // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
139 // cache invalidation during zero-downtime deployments. This must be called153 // cache invalidation during zero-downtime deployments. This must be called
140 // after construction because the proxy service is created after the container154 // after construction because the proxy service is created after the container
@@ -216,6 +230,15 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st
216 // Deploy creates and starts a container for the given route.230 // Deploy creates and starts a container for the given route.
217 // Implements zero-downtime deployment: new container starts before old one stops.231 // Implements zero-downtime deployment: new container starts before old one stops.
218 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {232 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
233+ ctx, span := tracer.Start(ctx, "container.deploy",
234+ trace.WithAttributes(
235+ attribute.String("domain", route.Domain),
236+ attribute.String("image", route.Image),
237+ ))
238+ defer span.End()
239+
240+ deployStart := time.Now()
241+
219 // Serialize deploys for the same domain to prevent race conditions242 // Serialize deploys for the same domain to prevent race conditions
220 // (e.g. multiple image.pushed events + explicit deploy call from CLI).243 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
221 unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)244 unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
@@ -232,6 +255,26 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
232 })255 })
233 log := zerowrap.FromCtx(ctx)256 log := zerowrap.FromCtx(ctx)
234 257
258+ // Record deploy metrics and trace status
259+ defer func() {
260+ if err != nil {
261+ span.RecordError(err)
262+ span.SetStatus(codes.Error, err.Error())
263+ }
264+ if s.metrics == nil {
265+ return
266+ }
267+ attrs := metric.WithAttributes(
268+ attribute.String("domain", route.Domain),
269+ attribute.String("image", route.Image),
270+ )
271+ s.metrics.DeployTotal.Add(ctx, 1, attrs)
272+ s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs)
273+ if err != nil {
274+ s.metrics.DeployErrors.Add(ctx, 1, attrs)
275+ }
276+ }()
277+
235 existing, hasExisting := s.getTrackedContainer(route.Domain)278 existing, hasExisting := s.getTrackedContainer(route.Domain)
236 279
237 resources, err := s.prepareDeployResources(ctx, route, existing)280 resources, err := s.prepareDeployResources(ctx, route, existing)
@@ -369,6 +412,9 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
369 }412 }
370 413
371 func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {414 func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
415+ ctx, span := tracer.Start(ctx, "container.create_and_start")
416+ defer span.End()
417+
372 log := zerowrap.FromCtx(ctx)418 log := zerowrap.FromCtx(ctx)
373 419
374 containerConfig := s.buildContainerConfig(420 containerConfig := s.buildContainerConfig(
@@ -408,9 +454,17 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
408 454
409 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {455 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
410 s.mu.Lock()456 s.mu.Lock()
457+ _, wasTracked := s.containers[domainName]
411 s.containers[domainName] = container458 s.containers[domainName] = container
412 s.mu.Unlock()459 s.mu.Unlock()
413 460
461+ // Track managed container count (only increment for new domains, not replacements)
462+ if !wasTracked && s.metrics != nil {
463+ s.metrics.ManagedContainers.Add(ctx, 1, metric.WithAttributes(
464+ attribute.String("domain", domainName),
465+ ))
466+ }
467+
414 s.publishContainerDeployed(ctx, domainName, container.ID)468 s.publishContainerDeployed(ctx, domainName, container.ID)
415 469
416 s.mu.RLock()470 s.mu.RLock()
@@ -539,6 +593,14 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
539 }593 }
540 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")594 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
541 595
596+ // Record restart metric
597+ if s.metrics != nil {
598+ s.metrics.ContainerRestarts.Add(ctx, 1, metric.WithAttributes(
599+ attribute.String("domain", domainName),
600+ attribute.String("source", "api"),
601+ ))
602+ }
603+
542 // Restart attachments if requested604 // Restart attachments if requested
543 if withAttachments && len(attachmentIDs) > 0 {605 if withAttachments && len(attachmentIDs) > 0 {
544 for _, attachID := range attachmentIDs {606 for _, attachID := range attachmentIDs {
@@ -626,6 +688,13 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
626 }688 }
627 s.mu.Unlock()689 s.mu.Unlock()
628 690
691+ // Decrement managed container count
692+ if removedDomain != "" && s.metrics != nil {
693+ s.metrics.ManagedContainers.Add(ctx, -1, metric.WithAttributes(
694+ attribute.String("domain", removedDomain),
695+ ))
696+ }
697+
629 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:698 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
630 // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,699 // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
631 // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent700 // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
@@ -1161,6 +1230,10 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
1161 // - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests1230 // - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
1162 // - For tagged images, returns the original imageRef after tagging the pulled image1231 // - For tagged images, returns the original imageRef after tagging the pulled image
1163 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {1232 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
1233+ ctx, span := tracer.Start(ctx, "container.ensure_image",
1234+ trace.WithAttributes(attribute.String("image", imageRef)))
1235+ defer span.End()
1236+
1164 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)1237 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
1165 log := zerowrap.FromCtx(ctx)1238 log := zerowrap.FromCtx(ctx)
1166 1239
M internal/usecase/proxy/service.go +71 -50
@@ -18,12 +18,18 @@ import (
1818 "time"
1919
2020 "github.com/bnema/zerowrap"
21+ "go.opentelemetry.io/otel"
22+ "go.opentelemetry.io/otel/attribute"
23+ "go.opentelemetry.io/otel/codes"
24+ "go.opentelemetry.io/otel/trace"
2125
2226 "github.com/bnema/gordon/internal/boundaries/in"
2327 "github.com/bnema/gordon/internal/boundaries/out"
2428 "github.com/bnema/gordon/internal/domain"
2529 )
2630
31+var proxyTracer = otel.Tracer("gordon.proxy")
32+
2733 // proxyTransport is a shared HTTP transport for proxying to application containers.
2834 // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
2935 var proxyTransport = &http.Transport{
@@ -155,7 +161,17 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
155161 }
156162
157163 // GetTarget returns the proxy target for a given domain.
158-func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
164+func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
165+ ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
166+ trace.WithAttributes(attribute.String("domain", domainName)))
167+ defer func() {
168+ if retErr != nil {
169+ span.RecordError(retErr)
170+ span.SetStatus(codes.Error, retErr.Error())
171+ }
172+ span.End()
173+ }()
174+
159175 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
160176 zerowrap.FieldLayer: "usecase",
161177 zerowrap.FieldUseCase: "GetTarget",
@@ -179,54 +195,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
179195 // Check if this is an external route
180196 externalRoutes := s.configSvc.GetExternalRoutes()
181197 if targetAddr, ok := externalRoutes[domainName]; ok {
182- host, portStr, err := net.SplitHostPort(targetAddr)
183- if err != nil {
184- return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
185- }
186- port, err := strconv.Atoi(portStr)
187- if err != nil {
188- return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
189- }
190-
191- // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
192- // network. We use the resolved IP as the proxy target to prevent DNS rebinding
193- // (TOCTOU) attacks where a hostname resolves to a public IP during validation
194- // but to a private IP when the proxy connects.
195- resolvedIP, err := ResolveAndValidateHost(host)
196- if err != nil {
197- log.Warn().
198- Err(err).
199- Str("host", host).
200- Str("domain", domainName).
201- Msg("SSRF protection: blocked external route to internal network")
202- return nil, err
203- }
204-
205- // Preserve the original hostname for the Host header so virtual-hosted
206- // upstreams work correctly. The resolved IP is used for dialing only.
207- var originalHost string
208- if resolvedIP != host {
209- originalHost = host
210- }
211-
212- target := &domain.ProxyTarget{
213- Host: resolvedIP,
214- Port: port,
215- ContainerID: "", // Not a container
216- Scheme: "http",
217- OriginalHost: originalHost,
218- }
219-
220- // Cache external route target
221- s.mu.Lock()
222- s.targets[domainName] = target
223- s.mu.Unlock()
224-
225- log.Debug().
226- Str("host", host).
227- Int("port", port).
228- Msg("using external route target")
229- return target, nil
198+ return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
230199 }
231200
232201 // Get container for this domain
@@ -238,7 +207,6 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
238207 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
239208
240209 // Build target based on runtime mode
241- var target *domain.ProxyTarget
242210
243211 if s.isRunningInContainer() {
244212 // Gordon is in a container - use container network
@@ -295,6 +263,59 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
295263 return target, nil
296264 }
297265
266+// resolveExternalRoute resolves an external route target address into a ProxyTarget,
267+// performing DNS validation and SSRF protection.
268+func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
269+ host, portStr, err := net.SplitHostPort(targetAddr)
270+ if err != nil {
271+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
272+ }
273+ port, err := strconv.Atoi(portStr)
274+ if err != nil {
275+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
276+ }
277+
278+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
279+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
280+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
281+ // but to a private IP when the proxy connects.
282+ resolvedIP, err := ResolveAndValidateHost(host)
283+ if err != nil {
284+ log.Warn().
285+ Err(err).
286+ Str("host", host).
287+ Str("domain", domainName).
288+ Msg("SSRF protection: blocked external route to internal network")
289+ return nil, err
290+ }
291+
292+ // Preserve the original hostname for the Host header so virtual-hosted
293+ // upstreams work correctly. The resolved IP is used for dialing only.
294+ var originalHost string
295+ if resolvedIP != host {
296+ originalHost = host
297+ }
298+
299+ t := &domain.ProxyTarget{
300+ Host: resolvedIP,
301+ Port: port,
302+ ContainerID: "", // Not a container
303+ Scheme: "http",
304+ OriginalHost: originalHost,
305+ }
306+
307+ // Cache external route target
308+ s.mu.Lock()
309+ s.targets[domainName] = t
310+ s.mu.Unlock()
311+
312+ log.Debug().
313+ Str("host", host).
314+ Int("port", port).
315+ Msg("using external route target")
316+ return t, nil
317+}
318+
298319 // RegisterTarget registers a new proxy target for a domain.
299320 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
300321 s.mu.Lock()
@@ -18,12 +18,18 @@ import (
18 "time"18 "time"
19 19
20 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
21+ "go.opentelemetry.io/otel"
22+ "go.opentelemetry.io/otel/attribute"
23+ "go.opentelemetry.io/otel/codes"
24+ "go.opentelemetry.io/otel/trace"
21 25
22 "github.com/bnema/gordon/internal/boundaries/in"26 "github.com/bnema/gordon/internal/boundaries/in"
23 "github.com/bnema/gordon/internal/boundaries/out"27 "github.com/bnema/gordon/internal/boundaries/out"
24 "github.com/bnema/gordon/internal/domain"28 "github.com/bnema/gordon/internal/domain"
25 )29 )
26 30
31+var proxyTracer = otel.Tracer("gordon.proxy")
32+
27 // proxyTransport is a shared HTTP transport for proxying to application containers.33 // proxyTransport is a shared HTTP transport for proxying to application containers.
28 // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.34 // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
29 var proxyTransport = &http.Transport{35 var proxyTransport = &http.Transport{
@@ -155,7 +161,17 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
155 }161 }
156 162
157 // GetTarget returns the proxy target for a given domain.163 // GetTarget returns the proxy target for a given domain.
158-func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {164+func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
165+ ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
166+ trace.WithAttributes(attribute.String("domain", domainName)))
167+ defer func() {
168+ if retErr != nil {
169+ span.RecordError(retErr)
170+ span.SetStatus(codes.Error, retErr.Error())
171+ }
172+ span.End()
173+ }()
174+
159 ctx = zerowrap.CtxWithFields(ctx, map[string]any{175 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
160 zerowrap.FieldLayer: "usecase",176 zerowrap.FieldLayer: "usecase",
161 zerowrap.FieldUseCase: "GetTarget",177 zerowrap.FieldUseCase: "GetTarget",
@@ -179,54 +195,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
179 // Check if this is an external route195 // Check if this is an external route
180 externalRoutes := s.configSvc.GetExternalRoutes()196 externalRoutes := s.configSvc.GetExternalRoutes()
181 if targetAddr, ok := externalRoutes[domainName]; ok {197 if targetAddr, ok := externalRoutes[domainName]; ok {
182- host, portStr, err := net.SplitHostPort(targetAddr)198+ return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
183- if err != nil {
184- return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
185- }
186- port, err := strconv.Atoi(portStr)
187- if err != nil {
188- return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
189- }
190-
191- // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
192- // network. We use the resolved IP as the proxy target to prevent DNS rebinding
193- // (TOCTOU) attacks where a hostname resolves to a public IP during validation
194- // but to a private IP when the proxy connects.
195- resolvedIP, err := ResolveAndValidateHost(host)
196- if err != nil {
197- log.Warn().
198- Err(err).
199- Str("host", host).
200- Str("domain", domainName).
201- Msg("SSRF protection: blocked external route to internal network")
202- return nil, err
203- }
204-
205- // Preserve the original hostname for the Host header so virtual-hosted
206- // upstreams work correctly. The resolved IP is used for dialing only.
207- var originalHost string
208- if resolvedIP != host {
209- originalHost = host
210- }
211-
212- target := &domain.ProxyTarget{
213- Host: resolvedIP,
214- Port: port,
215- ContainerID: "", // Not a container
216- Scheme: "http",
217- OriginalHost: originalHost,
218- }
219-
220- // Cache external route target
221- s.mu.Lock()
222- s.targets[domainName] = target
223- s.mu.Unlock()
224-
225- log.Debug().
226- Str("host", host).
227- Int("port", port).
228- Msg("using external route target")
229- return target, nil
230 }199 }
231 200
232 // Get container for this domain201 // Get container for this domain
@@ -238,7 +207,6 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
238 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")207 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
239 208
240 // Build target based on runtime mode209 // Build target based on runtime mode
241- var target *domain.ProxyTarget
242 210
243 if s.isRunningInContainer() {211 if s.isRunningInContainer() {
244 // Gordon is in a container - use container network212 // Gordon is in a container - use container network
@@ -295,6 +263,59 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
295 return target, nil263 return target, nil
296 }264 }
297 265
266+// resolveExternalRoute resolves an external route target address into a ProxyTarget,
267+// performing DNS validation and SSRF protection.
268+func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
269+ host, portStr, err := net.SplitHostPort(targetAddr)
270+ if err != nil {
271+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
272+ }
273+ port, err := strconv.Atoi(portStr)
274+ if err != nil {
275+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
276+ }
277+
278+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
279+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
280+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
281+ // but to a private IP when the proxy connects.
282+ resolvedIP, err := ResolveAndValidateHost(host)
283+ if err != nil {
284+ log.Warn().
285+ Err(err).
286+ Str("host", host).
287+ Str("domain", domainName).
288+ Msg("SSRF protection: blocked external route to internal network")
289+ return nil, err
290+ }
291+
292+ // Preserve the original hostname for the Host header so virtual-hosted
293+ // upstreams work correctly. The resolved IP is used for dialing only.
294+ var originalHost string
295+ if resolvedIP != host {
296+ originalHost = host
297+ }
298+
299+ t := &domain.ProxyTarget{
300+ Host: resolvedIP,
301+ Port: port,
302+ ContainerID: "", // Not a container
303+ Scheme: "http",
304+ OriginalHost: originalHost,
305+ }
306+
307+ // Cache external route target
308+ s.mu.Lock()
309+ s.targets[domainName] = t
310+ s.mu.Unlock()
311+
312+ log.Debug().
313+ Str("host", host).
314+ Int("port", port).
315+ Msg("using external route target")
316+ return t, nil
317+}
318+
298 // RegisterTarget registers a new proxy target for a domain.319 // RegisterTarget registers a new proxy target for a domain.
299 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {320 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
300 s.mu.Lock()321 s.mu.Lock()
M internal/usecase/registry/service.go +31 -0
@@ -9,17 +9,30 @@ import (
99 "strings"
1010
1111 "github.com/bnema/zerowrap"
12+ "go.opentelemetry.io/otel"
13+ "go.opentelemetry.io/otel/attribute"
14+ "go.opentelemetry.io/otel/metric"
15+ "go.opentelemetry.io/otel/trace"
1216
17+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
1318 "github.com/bnema/gordon/internal/boundaries/out"
1419 "github.com/bnema/gordon/internal/domain"
1520 "github.com/bnema/gordon/pkg/validation"
1621 )
1722
23+var registryTracer = otel.Tracer("gordon.registry")
24+
1825 // Service implements the RegistryService interface.
1926 type Service struct {
2027 blobStorage out.BlobStorage
2128 manifestStorage out.ManifestStorage
2229 eventBus out.EventPublisher
30+ metrics *telemetry.Metrics
31+}
32+
33+// SetMetrics sets the telemetry metrics for the registry service.
34+func (s *Service) SetMetrics(m *telemetry.Metrics) {
35+ s.metrics = m
2336 }
2437
2538 // NewService creates a new registry service.
@@ -60,6 +73,14 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
6073
6174 // PutManifest stores a manifest and returns the calculated digest.
6275 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
76+ ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
77+ trace.WithAttributes(
78+ attribute.String("name", manifest.Name),
79+ attribute.String("reference", manifest.Reference),
80+ attribute.Int("manifest_size", len(manifest.Data)),
81+ ))
82+ defer span.End()
83+
6384 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
6485 zerowrap.FieldLayer: "usecase",
6586 zerowrap.FieldUseCase: "PutManifest",
@@ -75,6 +96,16 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
7596 return "", log.WrapErr(err, "failed to store manifest")
7697 }
7798
99+ // Record push metrics
100+ if s.metrics != nil {
101+ attrs := metric.WithAttributes(
102+ attribute.String("name", manifest.Name),
103+ attribute.String("reference", manifest.Reference),
104+ )
105+ s.metrics.ImagePushTotal.Add(ctx, 1, attrs)
106+ s.metrics.ImagePushSize.Add(ctx, int64(len(manifest.Data)), attrs)
107+ }
108+
78109 // Publish image pushed event only for tag references (not digests).
79110 // A docker push sends manifests by both digest and tag; firing only on
80111 // tag prevents duplicate deploy triggers for the same push.
@@ -9,17 +9,30 @@ import (
9 "strings"9 "strings"
10 10
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12+ "go.opentelemetry.io/otel"
13+ "go.opentelemetry.io/otel/attribute"
14+ "go.opentelemetry.io/otel/metric"
15+ "go.opentelemetry.io/otel/trace"
12 16
17+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
13 "github.com/bnema/gordon/internal/boundaries/out"18 "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/pkg/validation"20 "github.com/bnema/gordon/pkg/validation"
16 )21 )
17 22
23+var registryTracer = otel.Tracer("gordon.registry")
24+
18 // Service implements the RegistryService interface.25 // Service implements the RegistryService interface.
19 type Service struct {26 type Service struct {
20 blobStorage out.BlobStorage27 blobStorage out.BlobStorage
21 manifestStorage out.ManifestStorage28 manifestStorage out.ManifestStorage
22 eventBus out.EventPublisher29 eventBus out.EventPublisher
30+ metrics *telemetry.Metrics
31+}
32+
33+// SetMetrics sets the telemetry metrics for the registry service.
34+func (s *Service) SetMetrics(m *telemetry.Metrics) {
35+ s.metrics = m
23 }36 }
24 37
25 // NewService creates a new registry service.38 // NewService creates a new registry service.
@@ -60,6 +73,14 @@ func (s *Service) GetManifest(ctx context.Context, name, reference string) (*dom
60 73
61 // PutManifest stores a manifest and returns the calculated digest.74 // PutManifest stores a manifest and returns the calculated digest.
62 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {75 func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (string, error) {
76+ ctx, span := registryTracer.Start(ctx, "registry.put_manifest",
77+ trace.WithAttributes(
78+ attribute.String("name", manifest.Name),
79+ attribute.String("reference", manifest.Reference),
80+ attribute.Int("manifest_size", len(manifest.Data)),
81+ ))
82+ defer span.End()
83+
63 ctx = zerowrap.CtxWithFields(ctx, map[string]any{84 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64 zerowrap.FieldLayer: "usecase",85 zerowrap.FieldLayer: "usecase",
65 zerowrap.FieldUseCase: "PutManifest",86 zerowrap.FieldUseCase: "PutManifest",
@@ -75,6 +96,16 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
75 return "", log.WrapErr(err, "failed to store manifest")96 return "", log.WrapErr(err, "failed to store manifest")
76 }97 }
77 98
99+ // Record push metrics
100+ if s.metrics != nil {
101+ attrs := metric.WithAttributes(
102+ attribute.String("name", manifest.Name),
103+ attribute.String("reference", manifest.Reference),
104+ )
105+ s.metrics.ImagePushTotal.Add(ctx, 1, attrs)
106+ s.metrics.ImagePushSize.Add(ctx, int64(len(manifest.Data)), attrs)
107+ }
108+
78 // Publish image pushed event only for tag references (not digests).109 // Publish image pushed event only for tag references (not digests).
79 // A docker push sends manifests by both digest and tag; firing only on110 // A docker push sends manifests by both digest and tag; firing only on
80 // tag prevents duplicate deploy triggers for the same push.111 // tag prevents duplicate deploy triggers for the same push.
M main.go +3 -1
@@ -4,6 +4,7 @@ import (
44 "os"
55
66 "github.com/bnema/gordon/internal/adapters/in/cli"
7+ versionpkg "github.com/bnema/gordon/pkg/version"
78 )
89
910 // Build information set by goreleaser via ldflags
@@ -14,7 +15,8 @@ var (
1415 )
1516
1617 func main() {
17- // Set version information for the CLI
18+ // Set version information globally and for the CLI
19+ versionpkg.Set(version, commit, date)
1820 cli.SetVersionInfo(version, commit, date)
1921
2022 if err := cli.NewRootCmd().Execute(); err != nil {
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 5
6 "github.com/bnema/gordon/internal/adapters/in/cli"6 "github.com/bnema/gordon/internal/adapters/in/cli"
7+ versionpkg "github.com/bnema/gordon/pkg/version"
7 )8 )
8 9
9 // Build information set by goreleaser via ldflags10 // Build information set by goreleaser via ldflags
@@ -14,7 +15,8 @@ var (
14 )15 )
15 16
16 func main() {17 func main() {
17- // Set version information for the CLI18+ // Set version information globally and for the CLI
19+ versionpkg.Set(version, commit, date)
18 cli.SetVersionInfo(version, commit, date)20 cli.SetVersionInfo(version, commit, date)
19 21
20 if err := cli.NewRootCmd().Execute(); err != nil {22 if err := cli.NewRootCmd().Execute(); err != nil {
A pkg/version/version.go +26 -0
@@ -0,0 +1,26 @@
1+// Package version holds build-time version info for Gordon.
2+// Set via main using Set(), read from anywhere via Version(), Commit(), or BuildDate().
3+package version
4+
5+// Build information, populated by Set() at startup.
6+var (
7+ version = "dev"
8+ commit = "unknown"
9+ buildDate = "unknown"
10+)
11+
12+// Set stores build-time version info. Call once from main.
13+func Set(v, c, d string) {
14+ version = v
15+ commit = c
16+ buildDate = d
17+}
18+
19+// Version returns the build version string.
20+func Version() string { return version }
21+
22+// Commit returns the build commit hash.
23+func Commit() string { return commit }
24+
25+// BuildDate returns the build date string.
26+func BuildDate() string { return buildDate }
@@ -0,0 +1,26 @@
1+// Package version holds build-time version info for Gordon.
2+// Set via main using Set(), read from anywhere via Version(), Commit(), or BuildDate().
3+package version
4+
5+// Build information, populated by Set() at startup.
6+var (
7+ version = "dev"
8+ commit = "unknown"
9+ buildDate = "unknown"
10+)
11+
12+// Set stores build-time version info. Call once from main.
13+func Set(v, c, d string) {
14+ version = v
15+ commit = c
16+ buildDate = d
17+}
18+
19+// Version returns the build version string.
20+func Version() string { return version }
21+
22+// Commit returns the build commit hash.
23+func Commit() string { return commit }
24+
25+// BuildDate returns the build date string.
26+func BuildDate() string { return buildDate }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close