feat(telemetry): add OpenTelemetry instrumentation for traces, metrics, and logs
Wire OTel SDK with OTLP/HTTP exporters into Gordon's bootstrap. Add telemetry provider, 10 custom metrics (deploy, container lifecycle, registry push, event bus), trace spans on critical paths (deploy, image pull, manifest push, proxy resolution), otelhttp middleware on both proxy and registry servers, and a zerolog-to-OTel log bridge. Includes [telemetry] config section with Viper defaults, docs for all options/metrics/spans, and updates to config reference pages.
- commit
- febe319ce025776d9ac281fa98c6e44ebf188565
- author
- brice · · 2026-02-10 07:14 UTC
- parent
- 4bbf7c1
- signature
- unsigned
15 files · +789 -9
M docs/config/index.md +19 -0
| @@ -82,6 +82,16 @@ max_size = 100 | |||
| 82 | 82 | max_backups = 3 | |
| 83 | 83 | 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 | 95 | # Environment variables | |
| 86 | 96 | [env] | |
| 87 | 97 | dir = "~/.gordon/env" # Default location | |
| @@ -144,6 +154,7 @@ keep_last = 3 | |||
| 144 | 154 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | |
| 145 | 155 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 146 | 156 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 157 | + | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | |
| 147 | 158 | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| 148 | 159 | | `[volumes]` | Volume management | [Volumes](./volumes.md) | | |
| 149 | 160 | | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) | | |
| @@ -194,6 +205,13 @@ keep_last = 3 | |||
| 194 | 205 | | `images.prune.enabled` | `false` | | |
| 195 | 206 | | `images.prune.schedule` | `"daily"` | | |
| 196 | 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 | 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 | 254 | - [Routes Configuration](./routes.md) | |
| 237 | 255 | - [External Routes](./external-routes.md) | |
| 238 | 256 | - [Authentication](./auth.md) | |
| 257 | + | - [Telemetry](./telemetry.md) | |
| 239 | 258 | - [Backups](./backups.md) | |
| 240 | 259 | - [Images](./images.md) | |
| @@ -82,6 +82,16 @@ max_size = 100 | |||||
| 82 | max_backups = 3 | 82 | max_backups = 3 | ||
| 83 | max_age = 28 | 83 | 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 variables | 95 | # Environment variables | ||
| 86 | [env] | 96 | [env] | ||
| 87 | dir = "~/.gordon/env" # Default location | 97 | 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 | |||
| 63 | 63 | max_backups = 3 # Number of old files to keep | |
| 64 | 64 | 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 | 79 | # ENVIRONMENT | |
| 68 | 80 | # ============================================================================= | |
| @@ -186,6 +198,13 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 186 | 198 | | `logging.container_logs.max_size` | `100` | 100 MB | | |
| 187 | 199 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 188 | 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 | 208 | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | |
| 190 | 209 | | `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) | | |
| 191 | 210 | | `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails | | |
| @@ -280,6 +299,7 @@ gordon serve | |||
| 280 | 299 | ||
| 281 | 300 | - [Configuration Overview](./index.md) | |
| 282 | 301 | - [Authentication](./auth.md) | |
| 302 | + | - [Telemetry](./telemetry.md) | |
| 283 | 303 | - [Network Isolation](./network-isolation.md) | |
| 284 | 304 | - [Volumes](./volumes.md) | |
| 285 | 305 | - [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 keep | 63 | max_backups = 3 # Number of old files to keep | ||
| 64 | max_age = 28 # Days to keep old files | 64 | 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 | # ENVIRONMENT | 79 | # 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 | + | ``` | |
| 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 | + | ``` | |||
| 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. | |||
| 6 | 6 | ||
| 7 | 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | |
| 8 | 8 | - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation | |
| 9 | + | - [Telemetry & Metrics](/docs/config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | |
| 9 | 10 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | |
| 10 | 11 | ||
| 11 | 12 | ## Related | |
| @@ -6,6 +6,7 @@ Technical reference documentation for Gordon. | |||||
| 6 | 6 | ||||
| 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | 7 | - [Docker Labels](./docker-labels.md) - Container and image labels used by Gordon | ||
| 8 | - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation | 8 | - [Environment Variables](./env-variables.md) - Environment variable syntax and interpolation | ||
| 9 | + | - [Telemetry & Metrics](/docs/config/telemetry.md) - OpenTelemetry configuration, custom metrics, and trace spans | |||
| 9 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | 10 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | ||
| 10 | 11 | ||||
| 11 | ## Related | 12 | ## Related | ||
M go.mod +20 -5
| @@ -14,6 +14,11 @@ require ( | |||
| 14 | 14 | github.com/spf13/cobra v1.10.2 | |
| 15 | 15 | github.com/spf13/viper v1.21.0 | |
| 16 | 16 | 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 | 22 | golang.org/x/crypto v0.47.0 | |
| 18 | 23 | golang.org/x/mod v0.32.0 | |
| 19 | 24 | golang.org/x/term v0.39.0 | |
| @@ -23,6 +28,7 @@ require ( | |||
| 23 | 28 | require ( | |
| 24 | 29 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 25 | 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 31 | + | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | |
| 26 | 32 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 27 | 33 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | |
| 28 | 34 | github.com/charmbracelet/x/ansi v0.11.5 // indirect | |
| @@ -32,6 +38,7 @@ require ( | |||
| 32 | 38 | github.com/clipperhouse/stringish v0.1.1 // indirect | |
| 33 | 39 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | |
| 34 | 40 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 41 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect | |
| 35 | 42 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 36 | 43 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 37 | 44 | github.com/mattn/go-runewidth v0.0.19 // indirect | |
| @@ -41,7 +48,15 @@ require ( | |||
| 41 | 48 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 42 | 49 | github.com/rs/zerolog v1.34.0 // indirect | |
| 43 | 50 | 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 | 54 | 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 | 62 | require ( | |
| @@ -81,11 +96,11 @@ require ( | |||
| 81 | 96 | github.com/spf13/pflag v1.0.10 // indirect | |
| 82 | 97 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 83 | 98 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | |
| 84 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect | |
| 85 | - | go.opentelemetry.io/otel v1.40.0 // indirect | |
| 86 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect | |
| 87 | - | go.opentelemetry.io/otel/metric v1.40.0 // indirect | |
| 88 | - | go.opentelemetry.io/otel/trace v1.40.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 | |
| 89 | 104 | golang.org/x/sys v0.40.0 // indirect | |
| 90 | 105 | golang.org/x/text v0.33.0 // indirect | |
| 91 | 106 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| @@ -14,6 +14,11 @@ require ( | |||||
| 14 | github.com/spf13/cobra v1.10.2 | 14 | github.com/spf13/cobra v1.10.2 | ||
| 15 | github.com/spf13/viper v1.21.0 | 15 | github.com/spf13/viper v1.21.0 | ||
| 16 | github.com/stretchr/testify v1.11.1 | 16 | 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.0 | 22 | golang.org/x/crypto v0.47.0 | ||
| 18 | golang.org/x/mod v0.32.0 | 23 | golang.org/x/mod v0.32.0 | ||
| 19 | golang.org/x/term v0.39.0 | 24 | golang.org/x/term v0.39.0 | ||
| @@ -23,6 +28,7 @@ require ( | |||||
| 23 | require ( | 28 | require ( | ||
| 24 | github.com/atotto/clipboard v0.1.4 // indirect | 29 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 25 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 30 | 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 // indirect | 32 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 27 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | 33 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | ||
| 28 | github.com/charmbracelet/x/ansi v0.11.5 // indirect | 34 | github.com/charmbracelet/x/ansi v0.11.5 // indirect | ||
| @@ -32,6 +38,7 @@ require ( | |||||
| 32 | github.com/clipperhouse/stringish v0.1.1 // indirect | 38 | github.com/clipperhouse/stringish v0.1.1 // indirect | ||
| 33 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | 39 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | ||
| 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 40 | 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 // indirect | 42 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| 36 | github.com/mattn/go-localereader v0.0.1 // indirect | 43 | github.com/mattn/go-localereader v0.0.1 // indirect | ||
| 37 | github.com/mattn/go-runewidth v0.0.19 // indirect | 44 | github.com/mattn/go-runewidth v0.0.19 // indirect | ||
| @@ -41,7 +48,15 @@ require ( | |||||
| 41 | github.com/rivo/uniseg v0.4.7 // indirect | 48 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| 42 | github.com/rs/zerolog v1.34.0 // indirect | 49 | github.com/rs/zerolog v1.34.0 // indirect | ||
| 43 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | 50 | 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 // indirect | 54 | 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,11 +96,11 @@ require ( | |||||
| 81 | github.com/spf13/pflag v1.0.10 // indirect | 96 | github.com/spf13/pflag v1.0.10 // indirect | ||
| 82 | github.com/subosito/gotenv v1.6.0 // indirect | 97 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 83 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | 98 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | ||
| 84 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect | 99 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 |
| 85 | - | go.opentelemetry.io/otel v1.40.0 // indirect | 100 | + | go.opentelemetry.io/otel v1.40.0 |
| 86 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect | 101 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 |
| 87 | - | go.opentelemetry.io/otel/metric v1.40.0 // indirect | 102 | + | go.opentelemetry.io/otel/metric v1.40.0 |
| 88 | - | go.opentelemetry.io/otel/trace v1.40.0 // indirect | 103 | + | go.opentelemetry.io/otel/trace v1.40.0 |
| 89 | golang.org/x/sys v0.40.0 // indirect | 104 | golang.org/x/sys v0.40.0 // indirect | ||
| 90 | golang.org/x/text v0.33.0 // indirect | 105 | golang.org/x/text v0.33.0 // indirect | ||
| 91 | gopkg.in/yaml.v3 v3.0.1 // indirect | 106 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
M go.sum +16 -0
| @@ -72,6 +72,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||
| 72 | 72 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | |
| 73 | 73 | github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= | |
| 74 | 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 | 77 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | |
| 76 | 78 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | |
| 77 | 79 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| @@ -165,20 +167,32 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb | |||
| 165 | 167 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= | |
| 166 | 168 | go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= | |
| 167 | 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= | |
| 168 | 174 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= | |
| 169 | 175 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= | |
| 170 | 176 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= | |
| 171 | 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= | |
| 172 | 180 | go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= | |
| 173 | 181 | go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= | |
| 174 | 182 | go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= | |
| 175 | 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= | |
| 176 | 188 | go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= | |
| 177 | 189 | go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= | |
| 178 | 190 | go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= | |
| 179 | 191 | go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= | |
| 180 | 192 | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= | |
| 181 | 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= | |
| 182 | 196 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | |
| 183 | 197 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | |
| 184 | 198 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| @@ -205,6 +219,8 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |||
| 205 | 219 | golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= | |
| 206 | 220 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | |
| 207 | 221 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= | |
| 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= | |
| 208 | 224 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= | |
| 209 | 225 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= | |
| 210 | 226 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= | |
| @@ -72,6 +72,8 @@ 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= | ||
| @@ -165,20 +167,32 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb | |||||
| 165 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= | 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.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= | 168 | go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= | ||
| 167 | go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= | 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= | |||
| 168 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= | 174 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= | ||
| 169 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= | 175 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= | ||
| 170 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= | 176 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= | ||
| 171 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= | 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= | |||
| 172 | go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= | 180 | go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= | ||
| 173 | go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= | 181 | go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= | ||
| 174 | go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= | 182 | go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= | ||
| 175 | go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= | 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= | |||
| 176 | go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= | 188 | go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= | ||
| 177 | go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= | 189 | go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= | ||
| 178 | go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= | 190 | go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= | ||
| 179 | go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= | 191 | go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= | ||
| 180 | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= | 192 | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= | ||
| 181 | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | 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= | |||
| 182 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | 196 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | ||
| 183 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | 197 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | ||
| 184 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | 198 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||
| @@ -205,6 +219,8 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |||||
| 205 | 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= | ||
| 206 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | 220 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | ||
| 207 | 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= | ||
| 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= | |||
| 208 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= | 224 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= | ||
| 209 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= | 225 | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= | ||
| 210 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= | 226 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= | ||
M internal/adapters/in/http/middleware/logging.go +13 -2
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/zerowrap" | |
| 15 | + | "go.opentelemetry.io/otel/trace" | |
| 15 | 16 | ||
| 16 | 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 | 96 | // IP spoofing via X-Forwarded-For from untrusted sources. | |
| 96 | 97 | clientIP := GetClientIP(r, nets) | |
| 97 | 98 | ||
| 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) | |
| 100 | 111 | r = r.WithContext(ctx) | |
| 101 | 112 | ||
| 102 | 113 | // Call the next handler | |
| @@ -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 handlers | 99 | + | // 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 handler | 113 | // Call the next handler | ||
M internal/adapters/out/eventbus/inmemory.go +25 -0
| @@ -9,7 +9,10 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/zerowrap" | |
| 11 | 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 | 16 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 18 | ) | |
| @@ -24,6 +27,14 @@ type InMemory struct { | |||
| 24 | 27 | cancel context.CancelFunc | |
| 25 | 28 | bufferSize int | |
| 26 | 29 | log zerowrap.Logger | |
| 30 | + | metrics *telemetry.Metrics | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // SetMetrics sets the telemetry metrics for the event bus. | |
| 34 | + | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | |
| 35 | + | bus.mu.Lock() | |
| 36 | + | bus.metrics = m | |
| 37 | + | bus.mu.Unlock() | |
| 27 | 38 | } | |
| 28 | 39 | ||
| 29 | 40 | // NewInMemory creates a new in-memory event bus. | |
| @@ -83,6 +94,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||
| 83 | 94 | Str("image_name", event.ImageName). | |
| 84 | 95 | Str("tag", event.Tag). | |
| 85 | 96 | Msg("event channel is full, dropping event after 5s timeout") | |
| 97 | + | ||
| 98 | + | // Record dropped event metric | |
| 99 | + | if bus.metrics != nil { | |
| 100 | + | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | |
| 101 | + | attribute.String("event_type", string(event.Type)), | |
| 102 | + | )) | |
| 103 | + | } | |
| 86 | 104 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | |
| 87 | 105 | } | |
| 88 | 106 | } | |
| @@ -217,6 +235,13 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||
| 217 | 235 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | |
| 218 | 236 | Dur(zerowrap.FieldDuration, time.Since(start)). | |
| 219 | 237 | Msg("event handled successfully") | |
| 238 | + | ||
| 239 | + | // Record processed event metric | |
| 240 | + | if bus.metrics != nil { | |
| 241 | + | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | |
| 242 | + | attribute.String("event_type", string(event.Type)), | |
| 243 | + | )) | |
| 244 | + | } | |
| 220 | 245 | } | |
| 221 | 246 | case <-ctx.Done(): | |
| 222 | 247 | 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,14 @@ type InMemory struct { | |||||
| 24 | cancel context.CancelFunc | 27 | cancel context.CancelFunc | ||
| 25 | bufferSize int | 28 | bufferSize int | ||
| 26 | log zerowrap.Logger | 29 | log zerowrap.Logger | ||
| 30 | + | metrics *telemetry.Metrics | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // SetMetrics sets the telemetry metrics for the event bus. | |||
| 34 | + | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | |||
| 35 | + | bus.mu.Lock() | |||
| 36 | + | bus.metrics = m | |||
| 37 | + | bus.mu.Unlock() | |||
| 27 | } | 38 | } | ||
| 28 | 39 | ||||
| 29 | // NewInMemory creates a new in-memory event bus. | 40 | // NewInMemory creates a new in-memory event bus. | ||
| @@ -83,6 +94,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||||
| 83 | Str("image_name", event.ImageName). | 94 | Str("image_name", event.ImageName). | ||
| 84 | Str("tag", event.Tag). | 95 | Str("tag", event.Tag). | ||
| 85 | Msg("event channel is full, dropping event after 5s timeout") | 96 | Msg("event channel is full, dropping event after 5s timeout") | ||
| 97 | + | ||||
| 98 | + | // Record dropped event metric | |||
| 99 | + | if bus.metrics != nil { | |||
| 100 | + | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | |||
| 101 | + | attribute.String("event_type", string(event.Type)), | |||
| 102 | + | )) | |||
| 103 | + | } | |||
| 86 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | 104 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | ||
| 87 | } | 105 | } | ||
| 88 | } | 106 | } | ||
| @@ -217,6 +235,13 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||||
| 217 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | 235 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | ||
| 218 | Dur(zerowrap.FieldDuration, time.Since(start)). | 236 | Dur(zerowrap.FieldDuration, time.Since(start)). | ||
| 219 | Msg("event handled successfully") | 237 | Msg("event handled successfully") | ||
| 238 | + | ||||
| 239 | + | // Record processed event metric | |||
| 240 | + | if bus.metrics != nil { | |||
| 241 | + | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | |||
| 242 | + | attribute.String("event_type", string(event.Type)), | |||
| 243 | + | )) | |||
| 244 | + | } | |||
| 220 | } | 245 | } | ||
| 221 | case <-ctx.Done(): | 246 | case <-ctx.Done(): | ||
| 222 | cancel() | 247 | cancel() | ||
A internal/adapters/out/telemetry/metrics.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 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")); err != nil { | |
| 44 | + | return nil, err | |
| 45 | + | } | |
| 46 | + | if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors", | |
| 47 | + | metric.WithDescription("Total deploy errors")); err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts", | |
| 51 | + | metric.WithDescription("Total container restarts")); err != nil { | |
| 52 | + | return nil, err | |
| 53 | + | } | |
| 54 | + | if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops", | |
| 55 | + | metric.WithDescription("Total crash loop detections")); err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed", | |
| 59 | + | metric.WithDescription("Currently managed containers")); err != nil { | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total", | |
| 63 | + | metric.WithDescription("Total image pushes")); err != nil { | |
| 64 | + | return nil, err | |
| 65 | + | } | |
| 66 | + | if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes", | |
| 67 | + | metric.WithDescription("Total bytes pushed to registry"), | |
| 68 | + | metric.WithUnit("By")); err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed", | |
| 72 | + | metric.WithDescription("Total events processed")); err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 75 | + | if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped", | |
| 76 | + | metric.WithDescription("Total events dropped")); err != nil { | |
| 77 | + | return nil, err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | return m, nil | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 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")); err != nil { | |||
| 44 | + | return nil, err | |||
| 45 | + | } | |||
| 46 | + | if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors", | |||
| 47 | + | metric.WithDescription("Total deploy errors")); err != nil { | |||
| 48 | + | return nil, err | |||
| 49 | + | } | |||
| 50 | + | if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts", | |||
| 51 | + | metric.WithDescription("Total container restarts")); err != nil { | |||
| 52 | + | return nil, err | |||
| 53 | + | } | |||
| 54 | + | if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops", | |||
| 55 | + | metric.WithDescription("Total crash loop detections")); err != nil { | |||
| 56 | + | return nil, err | |||
| 57 | + | } | |||
| 58 | + | if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed", | |||
| 59 | + | metric.WithDescription("Currently managed containers")); err != nil { | |||
| 60 | + | return nil, err | |||
| 61 | + | } | |||
| 62 | + | if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total", | |||
| 63 | + | metric.WithDescription("Total image pushes")); err != nil { | |||
| 64 | + | return nil, err | |||
| 65 | + | } | |||
| 66 | + | if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes", | |||
| 67 | + | metric.WithDescription("Total bytes pushed to registry"), | |||
| 68 | + | metric.WithUnit("By")); err != nil { | |||
| 69 | + | return nil, err | |||
| 70 | + | } | |||
| 71 | + | if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed", | |||
| 72 | + | metric.WithDescription("Total events processed")); err != nil { | |||
| 73 | + | return nil, err | |||
| 74 | + | } | |||
| 75 | + | if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped", | |||
| 76 | + | metric.WithDescription("Total events dropped")); err != nil { | |||
| 77 | + | return nil, err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | return m, nil | |||
| 81 | + | } | |||
A internal/adapters/out/telemetry/provider.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 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 | + | ||
| 10 | + | "go.opentelemetry.io/otel" | |
| 11 | + | "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" | |
| 12 | + | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" | |
| 13 | + | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" | |
| 14 | + | otellog "go.opentelemetry.io/otel/sdk/log" | |
| 15 | + | "go.opentelemetry.io/otel/sdk/metric" | |
| 16 | + | "go.opentelemetry.io/otel/sdk/resource" | |
| 17 | + | "go.opentelemetry.io/otel/sdk/trace" | |
| 18 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Config holds telemetry configuration. | |
| 22 | + | type Config struct { | |
| 23 | + | Enabled bool `mapstructure:"enabled"` | |
| 24 | + | Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318" | |
| 25 | + | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | |
| 26 | + | Traces bool `mapstructure:"traces"` // Enable trace export | |
| 27 | + | Metrics bool `mapstructure:"metrics"` // Enable metric export | |
| 28 | + | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | |
| 29 | + | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0.0-1.0, default 1.0 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Provider holds the initialized OTel providers. | |
| 33 | + | type Provider struct { | |
| 34 | + | TracerProvider *trace.TracerProvider | |
| 35 | + | MeterProvider *metric.MeterProvider | |
| 36 | + | LogProvider *otellog.LoggerProvider | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // endpointConfig holds parsed endpoint details shared across exporter setup. | |
| 40 | + | type endpointConfig struct { | |
| 41 | + | host string | |
| 42 | + | basePath string | |
| 43 | + | insecure bool | |
| 44 | + | headers map[string]string | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // NewProvider creates and configures OTel providers based on the given config. | |
| 48 | + | // Returns a noop provider if telemetry is disabled. | |
| 49 | + | // The returned shutdown function must be called on application exit. | |
| 50 | + | func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) { | |
| 51 | + | noop := func(context.Context) {} | |
| 52 | + | ||
| 53 | + | if !cfg.Enabled || cfg.Endpoint == "" { | |
| 54 | + | return &Provider{}, noop, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | res, err := resource.New(ctx, | |
| 58 | + | resource.WithAttributes( | |
| 59 | + | semconv.ServiceName(serviceName), | |
| 60 | + | semconv.ServiceVersion(version), | |
| 61 | + | ), | |
| 62 | + | resource.WithOS(), | |
| 63 | + | resource.WithHost(), | |
| 64 | + | ) | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, noop, fmt.Errorf("create resource: %w", err) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | ep, err := parseEndpoint(cfg) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, noop, err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | var shutdowns []func(context.Context) error | |
| 75 | + | p := &Provider{} | |
| 76 | + | ||
| 77 | + | if cfg.Traces { | |
| 78 | + | if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil { | |
| 79 | + | return nil, noop, err | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if cfg.Metrics { | |
| 84 | + | if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil { | |
| 85 | + | return nil, noop, err | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if cfg.Logs { | |
| 90 | + | if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil { | |
| 91 | + | return nil, noop, err | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | shutdown := func(ctx context.Context) { | |
| 96 | + | for _, fn := range shutdowns { | |
| 97 | + | _ = fn(ctx) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return p, shutdown, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // parseEndpoint extracts host, path, and scheme from the configured endpoint URL. | |
| 105 | + | func parseEndpoint(cfg Config) (*endpointConfig, error) { | |
| 106 | + | parsedURL, err := url.Parse(cfg.Endpoint) | |
| 107 | + | if err != nil { | |
| 108 | + | return nil, fmt.Errorf("parse endpoint URL: %w", err) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | headers := make(map[string]string) | |
| 112 | + | if cfg.AuthToken != "" { | |
| 113 | + | headers["Authorization"] = "Basic " + cfg.AuthToken | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return &endpointConfig{ | |
| 117 | + | host: parsedURL.Host, | |
| 118 | + | basePath: parsedURL.Path, | |
| 119 | + | insecure: parsedURL.Scheme == "http", | |
| 120 | + | headers: headers, | |
| 121 | + | }, nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // setupTracing initializes the trace provider with an OTLP/HTTP exporter. | |
| 125 | + | func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 126 | + | traceOpts := []otlptracehttp.Option{ | |
| 127 | + | otlptracehttp.WithEndpoint(ep.host), | |
| 128 | + | otlptracehttp.WithHeaders(ep.headers), | |
| 129 | + | } | |
| 130 | + | if ep.basePath != "" { | |
| 131 | + | traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces")) | |
| 132 | + | } | |
| 133 | + | if ep.insecure { | |
| 134 | + | traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | traceExp, err := otlptracehttp.New(ctx, traceOpts...) | |
| 138 | + | if err != nil { | |
| 139 | + | return fmt.Errorf("create trace exporter: %w", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | sampler := trace.AlwaysSample() | |
| 143 | + | if cfg.TraceSampleRate == 0 { | |
| 144 | + | sampler = trace.NeverSample() | |
| 145 | + | } else if cfg.TraceSampleRate < 1.0 { | |
| 146 | + | sampler = trace.TraceIDRatioBased(cfg.TraceSampleRate) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | tp := trace.NewTracerProvider( | |
| 150 | + | trace.WithBatcher(traceExp), | |
| 151 | + | trace.WithResource(res), | |
| 152 | + | trace.WithSampler(sampler), | |
| 153 | + | ) | |
| 154 | + | otel.SetTracerProvider(tp) | |
| 155 | + | p.TracerProvider = tp | |
| 156 | + | *shutdowns = append(*shutdowns, tp.Shutdown) | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // setupMetrics initializes the meter provider with an OTLP/HTTP exporter. | |
| 161 | + | func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 162 | + | metricOpts := []otlpmetrichttp.Option{ | |
| 163 | + | otlpmetrichttp.WithEndpoint(ep.host), | |
| 164 | + | otlpmetrichttp.WithHeaders(ep.headers), | |
| 165 | + | } | |
| 166 | + | if ep.basePath != "" { | |
| 167 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics")) | |
| 168 | + | } | |
| 169 | + | if ep.insecure { | |
| 170 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) | |
| 174 | + | if err != nil { | |
| 175 | + | return fmt.Errorf("create metric exporter: %w", err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | mp := metric.NewMeterProvider( | |
| 179 | + | metric.WithReader(metric.NewPeriodicReader(metricExp)), | |
| 180 | + | metric.WithResource(res), | |
| 181 | + | ) | |
| 182 | + | otel.SetMeterProvider(mp) | |
| 183 | + | p.MeterProvider = mp | |
| 184 | + | *shutdowns = append(*shutdowns, mp.Shutdown) | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // setupLogging initializes the log provider with an OTLP/HTTP exporter. | |
| 189 | + | func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 190 | + | logOpts := []otlploghttp.Option{ | |
| 191 | + | otlploghttp.WithEndpoint(ep.host), | |
| 192 | + | otlploghttp.WithHeaders(ep.headers), | |
| 193 | + | } | |
| 194 | + | if ep.basePath != "" { | |
| 195 | + | logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs")) | |
| 196 | + | } | |
| 197 | + | if ep.insecure { | |
| 198 | + | logOpts = append(logOpts, otlploghttp.WithInsecure()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | logExp, err := otlploghttp.New(ctx, logOpts...) | |
| 202 | + | if err != nil { | |
| 203 | + | return fmt.Errorf("create log exporter: %w", err) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | lp := otellog.NewLoggerProvider( | |
| 207 | + | otellog.WithProcessor(otellog.NewBatchProcessor(logExp)), | |
| 208 | + | otellog.WithResource(res), | |
| 209 | + | ) | |
| 210 | + | p.LogProvider = lp | |
| 211 | + | *shutdowns = append(*shutdowns, lp.Shutdown) | |
| 212 | + | return nil | |
| 213 | + | } | |
| @@ -0,0 +1,213 @@ | |||||
| 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 | + | ||||
| 10 | + | "go.opentelemetry.io/otel" | |||
| 11 | + | "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" | |||
| 12 | + | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" | |||
| 13 | + | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" | |||
| 14 | + | otellog "go.opentelemetry.io/otel/sdk/log" | |||
| 15 | + | "go.opentelemetry.io/otel/sdk/metric" | |||
| 16 | + | "go.opentelemetry.io/otel/sdk/resource" | |||
| 17 | + | "go.opentelemetry.io/otel/sdk/trace" | |||
| 18 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Config holds telemetry configuration. | |||
| 22 | + | type Config struct { | |||
| 23 | + | Enabled bool `mapstructure:"enabled"` | |||
| 24 | + | Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318" | |||
| 25 | + | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | |||
| 26 | + | Traces bool `mapstructure:"traces"` // Enable trace export | |||
| 27 | + | Metrics bool `mapstructure:"metrics"` // Enable metric export | |||
| 28 | + | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | |||
| 29 | + | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0.0-1.0, default 1.0 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Provider holds the initialized OTel providers. | |||
| 33 | + | type Provider struct { | |||
| 34 | + | TracerProvider *trace.TracerProvider | |||
| 35 | + | MeterProvider *metric.MeterProvider | |||
| 36 | + | LogProvider *otellog.LoggerProvider | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // endpointConfig holds parsed endpoint details shared across exporter setup. | |||
| 40 | + | type endpointConfig struct { | |||
| 41 | + | host string | |||
| 42 | + | basePath string | |||
| 43 | + | insecure bool | |||
| 44 | + | headers map[string]string | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // NewProvider creates and configures OTel providers based on the given config. | |||
| 48 | + | // Returns a noop provider if telemetry is disabled. | |||
| 49 | + | // The returned shutdown function must be called on application exit. | |||
| 50 | + | func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) { | |||
| 51 | + | noop := func(context.Context) {} | |||
| 52 | + | ||||
| 53 | + | if !cfg.Enabled || cfg.Endpoint == "" { | |||
| 54 | + | return &Provider{}, noop, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | res, err := resource.New(ctx, | |||
| 58 | + | resource.WithAttributes( | |||
| 59 | + | semconv.ServiceName(serviceName), | |||
| 60 | + | semconv.ServiceVersion(version), | |||
| 61 | + | ), | |||
| 62 | + | resource.WithOS(), | |||
| 63 | + | resource.WithHost(), | |||
| 64 | + | ) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, noop, fmt.Errorf("create resource: %w", err) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | ep, err := parseEndpoint(cfg) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return nil, noop, err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | var shutdowns []func(context.Context) error | |||
| 75 | + | p := &Provider{} | |||
| 76 | + | ||||
| 77 | + | if cfg.Traces { | |||
| 78 | + | if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil { | |||
| 79 | + | return nil, noop, err | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if cfg.Metrics { | |||
| 84 | + | if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil { | |||
| 85 | + | return nil, noop, err | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | if cfg.Logs { | |||
| 90 | + | if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil { | |||
| 91 | + | return nil, noop, err | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | shutdown := func(ctx context.Context) { | |||
| 96 | + | for _, fn := range shutdowns { | |||
| 97 | + | _ = fn(ctx) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return p, shutdown, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // parseEndpoint extracts host, path, and scheme from the configured endpoint URL. | |||
| 105 | + | func parseEndpoint(cfg Config) (*endpointConfig, error) { | |||
| 106 | + | parsedURL, err := url.Parse(cfg.Endpoint) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return nil, fmt.Errorf("parse endpoint URL: %w", err) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | headers := make(map[string]string) | |||
| 112 | + | if cfg.AuthToken != "" { | |||
| 113 | + | headers["Authorization"] = "Basic " + cfg.AuthToken | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return &endpointConfig{ | |||
| 117 | + | host: parsedURL.Host, | |||
| 118 | + | basePath: parsedURL.Path, | |||
| 119 | + | insecure: parsedURL.Scheme == "http", | |||
| 120 | + | headers: headers, | |||
| 121 | + | }, nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // setupTracing initializes the trace provider with an OTLP/HTTP exporter. | |||
| 125 | + | func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 126 | + | traceOpts := []otlptracehttp.Option{ | |||
| 127 | + | otlptracehttp.WithEndpoint(ep.host), | |||
| 128 | + | otlptracehttp.WithHeaders(ep.headers), | |||
| 129 | + | } | |||
| 130 | + | if ep.basePath != "" { | |||
| 131 | + | traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces")) | |||
| 132 | + | } | |||
| 133 | + | if ep.insecure { | |||
| 134 | + | traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | traceExp, err := otlptracehttp.New(ctx, traceOpts...) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return fmt.Errorf("create trace exporter: %w", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | sampler := trace.AlwaysSample() | |||
| 143 | + | if cfg.TraceSampleRate == 0 { | |||
| 144 | + | sampler = trace.NeverSample() | |||
| 145 | + | } else if cfg.TraceSampleRate < 1.0 { | |||
| 146 | + | sampler = trace.TraceIDRatioBased(cfg.TraceSampleRate) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | tp := trace.NewTracerProvider( | |||
| 150 | + | trace.WithBatcher(traceExp), | |||
| 151 | + | trace.WithResource(res), | |||
| 152 | + | trace.WithSampler(sampler), | |||
| 153 | + | ) | |||
| 154 | + | otel.SetTracerProvider(tp) | |||
| 155 | + | p.TracerProvider = tp | |||
| 156 | + | *shutdowns = append(*shutdowns, tp.Shutdown) | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // setupMetrics initializes the meter provider with an OTLP/HTTP exporter. | |||
| 161 | + | func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 162 | + | metricOpts := []otlpmetrichttp.Option{ | |||
| 163 | + | otlpmetrichttp.WithEndpoint(ep.host), | |||
| 164 | + | otlpmetrichttp.WithHeaders(ep.headers), | |||
| 165 | + | } | |||
| 166 | + | if ep.basePath != "" { | |||
| 167 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics")) | |||
| 168 | + | } | |||
| 169 | + | if ep.insecure { | |||
| 170 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return fmt.Errorf("create metric exporter: %w", err) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | mp := metric.NewMeterProvider( | |||
| 179 | + | metric.WithReader(metric.NewPeriodicReader(metricExp)), | |||
| 180 | + | metric.WithResource(res), | |||
| 181 | + | ) | |||
| 182 | + | otel.SetMeterProvider(mp) | |||
| 183 | + | p.MeterProvider = mp | |||
| 184 | + | *shutdowns = append(*shutdowns, mp.Shutdown) | |||
| 185 | + | return nil | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // setupLogging initializes the log provider with an OTLP/HTTP exporter. | |||
| 189 | + | func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 190 | + | logOpts := []otlploghttp.Option{ | |||
| 191 | + | otlploghttp.WithEndpoint(ep.host), | |||
| 192 | + | otlploghttp.WithHeaders(ep.headers), | |||
| 193 | + | } | |||
| 194 | + | if ep.basePath != "" { | |||
| 195 | + | logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs")) | |||
| 196 | + | } | |||
| 197 | + | if ep.insecure { | |||
| 198 | + | logOpts = append(logOpts, otlploghttp.WithInsecure()) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | logExp, err := otlploghttp.New(ctx, logOpts...) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return fmt.Errorf("create log exporter: %w", err) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | lp := otellog.NewLoggerProvider( | |||
| 207 | + | otellog.WithProcessor(otellog.NewBatchProcessor(logExp)), | |||
| 208 | + | otellog.WithResource(res), | |||
| 209 | + | ) | |||
| 210 | + | p.LogProvider = lp | |||
| 211 | + | *shutdowns = append(*shutdowns, lp.Shutdown) | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
M internal/app/run.go +52 -2
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "time" | |
| 26 | 26 | ||
| 27 | 27 | "github.com/bnema/zerowrap" | |
| 28 | + | zerowrapotel "github.com/bnema/zerowrap/otel" | |
| 28 | 29 | "github.com/fsnotify/fsnotify" | |
| 29 | 30 | "github.com/spf13/viper" | |
| 30 | 31 | ||
| @@ -38,8 +39,12 @@ import ( | |||
| 38 | 39 | "github.com/bnema/gordon/internal/adapters/out/logwriter" | |
| 39 | 40 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | |
| 40 | 41 | "github.com/bnema/gordon/internal/adapters/out/secrets" | |
| 42 | + | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 41 | 43 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | |
| 42 | 44 | ||
| 45 | + | // OTel | |
| 46 | + | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" | |
| 47 | + | ||
| 43 | 48 | // Adapters - Input | |
| 44 | 49 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 45 | 50 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| @@ -153,6 +158,8 @@ type Config struct { | |||
| 153 | 158 | KeepLast int `mapstructure:"keep_last"` | |
| 154 | 159 | } `mapstructure:"prune"` | |
| 155 | 160 | } `mapstructure:"images"` | |
| 161 | + | ||
| 162 | + | Telemetry telemetry.Config `mapstructure:"telemetry"` | |
| 156 | 163 | } | |
| 157 | 164 | ||
| 158 | 165 | // services holds all the services used by the application. | |
| @@ -201,6 +208,24 @@ func Run(ctx context.Context, configPath string) error { | |||
| 201 | 208 | } | |
| 202 | 209 | ||
| 203 | 210 | ctx = zerowrap.WithCtx(ctx, log) | |
| 211 | + | ||
| 212 | + | // Initialize OpenTelemetry | |
| 213 | + | telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", "dev") | |
| 214 | + | if err != nil { | |
| 215 | + | log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it") | |
| 216 | + | } else { | |
| 217 | + | defer telShutdown(ctx) | |
| 218 | + | if cfg.Telemetry.Enabled { | |
| 219 | + | // Bridge zerowrap logs to OTel if log export is enabled | |
| 220 | + | if cfg.Telemetry.Logs && telProvider.LogProvider != nil { | |
| 221 | + | otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon") | |
| 222 | + | log = zerowrap.WithHook(log, otelHook) | |
| 223 | + | ctx = zerowrap.WithCtx(ctx, log) | |
| 224 | + | } | |
| 225 | + | log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized") | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 204 | 229 | log.Info().Msg("Gordon starting") | |
| 205 | 230 | ||
| 206 | 231 | if err := ensureTLSConfig(&cfg, log); err != nil { | |
| @@ -470,6 +495,16 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 470 | 495 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 471 | 496 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | |
| 472 | 497 | ||
| 498 | + | // Initialize and inject telemetry metrics into services | |
| 499 | + | gordonMetrics, err := telemetry.NewMetrics() | |
| 500 | + | if err != nil { | |
| 501 | + | log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics") | |
| 502 | + | } else { | |
| 503 | + | svc.containerSvc.SetMetrics(gordonMetrics) | |
| 504 | + | svc.registrySvc.SetMetrics(gordonMetrics) | |
| 505 | + | svc.eventBus.SetMetrics(gordonMetrics) | |
| 506 | + | } | |
| 507 | + | ||
| 473 | 508 | proxyCfg, err := buildProxyConfig(cfg, log) | |
| 474 | 509 | if err != nil { | |
| 475 | 510 | return nil, err | |
| @@ -1328,7 +1363,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1328 | 1363 | middleware.SecurityHeaders, | |
| 1329 | 1364 | } | |
| 1330 | 1365 | ||
| 1331 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 1366 | + | proxyWithMiddleware := otelhttp.NewHandler( | |
| 1367 | + | middleware.Chain(proxyMiddlewares...)(svc.proxySvc), | |
| 1368 | + | "gordon.proxy", | |
| 1369 | + | ) | |
| 1332 | 1370 | proxyMux := http.NewServeMux() | |
| 1333 | 1371 | proxyMux.Handle("/", proxyWithMiddleware) | |
| 1334 | 1372 | ||
| @@ -1360,7 +1398,11 @@ func buildRegistryHandlerWithMiddleware( | |||
| 1360 | 1398 | ||
| 1361 | 1399 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | |
| 1362 | 1400 | ||
| 1363 | - | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1401 | + | registryWithOtel := otelhttp.NewHandler( | |
| 1402 | + | middleware.Chain(registryMiddlewares...)(registryHandler), | |
| 1403 | + | "gordon.registry", | |
| 1404 | + | ) | |
| 1405 | + | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1364 | 1406 | } | |
| 1365 | 1407 | ||
| 1366 | 1408 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| @@ -2219,6 +2261,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2219 | 2261 | v.SetDefault("images.prune.enabled", false) | |
| 2220 | 2262 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | |
| 2221 | 2263 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | |
| 2264 | + | v.SetDefault("telemetry.enabled", false) | |
| 2265 | + | v.SetDefault("telemetry.endpoint", "") | |
| 2266 | + | v.SetDefault("telemetry.auth_token", "") | |
| 2267 | + | v.SetDefault("telemetry.traces", true) | |
| 2268 | + | v.SetDefault("telemetry.metrics", true) | |
| 2269 | + | v.SetDefault("telemetry.logs", true) | |
| 2270 | + | v.SetDefault("telemetry.trace_sample_rate", 1.0) | |
| 2271 | + | ||
| 2222 | 2272 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 2223 | 2273 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 2224 | 2274 | 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 - Input | 48 | // 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" | ||
| @@ -153,6 +158,8 @@ type Config struct { | |||||
| 153 | KeepLast int `mapstructure:"keep_last"` | 158 | KeepLast int `mapstructure:"keep_last"` | ||
| 154 | } `mapstructure:"prune"` | 159 | } `mapstructure:"prune"` | ||
| 155 | } `mapstructure:"images"` | 160 | } `mapstructure:"images"` | ||
| 161 | + | ||||
| 162 | + | Telemetry telemetry.Config `mapstructure:"telemetry"` | |||
| 156 | } | 163 | } | ||
| 157 | 164 | ||||
| 158 | // services holds all the services used by the application. | 165 | // services holds all the services used by the application. | ||
| @@ -201,6 +208,24 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 201 | } | 208 | } | ||
| 202 | 209 | ||||
| 203 | ctx = zerowrap.WithCtx(ctx, log) | 210 | ctx = zerowrap.WithCtx(ctx, log) | ||
| 211 | + | ||||
| 212 | + | // Initialize OpenTelemetry | |||
| 213 | + | telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", "dev") | |||
| 214 | + | if err != nil { | |||
| 215 | + | log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it") | |||
| 216 | + | } else { | |||
| 217 | + | defer telShutdown(ctx) | |||
| 218 | + | if cfg.Telemetry.Enabled { | |||
| 219 | + | // Bridge zerowrap logs to OTel if log export is enabled | |||
| 220 | + | if cfg.Telemetry.Logs && telProvider.LogProvider != nil { | |||
| 221 | + | otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon") | |||
| 222 | + | log = zerowrap.WithHook(log, otelHook) | |||
| 223 | + | ctx = zerowrap.WithCtx(ctx, log) | |||
| 224 | + | } | |||
| 225 | + | log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized") | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 204 | log.Info().Msg("Gordon starting") | 229 | log.Info().Msg("Gordon starting") | ||
| 205 | 230 | ||||
| 206 | if err := ensureTLSConfig(&cfg, log); err != nil { | 231 | if err := ensureTLSConfig(&cfg, log); err != nil { | ||
| @@ -470,6 +495,16 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 470 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 495 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 471 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | 496 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | ||
| 472 | 497 | ||||
| 498 | + | // Initialize and inject telemetry metrics into services | |||
| 499 | + | gordonMetrics, err := telemetry.NewMetrics() | |||
| 500 | + | if err != nil { | |||
| 501 | + | log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics") | |||
| 502 | + | } else { | |||
| 503 | + | svc.containerSvc.SetMetrics(gordonMetrics) | |||
| 504 | + | svc.registrySvc.SetMetrics(gordonMetrics) | |||
| 505 | + | svc.eventBus.SetMetrics(gordonMetrics) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 473 | proxyCfg, err := buildProxyConfig(cfg, log) | 508 | proxyCfg, err := buildProxyConfig(cfg, log) | ||
| 474 | if err != nil { | 509 | if err != nil { | ||
| 475 | return nil, err | 510 | return nil, err | ||
| @@ -1328,7 +1363,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1328 | middleware.SecurityHeaders, | 1363 | middleware.SecurityHeaders, | ||
| 1329 | } | 1364 | } | ||
| 1330 | 1365 | ||||
| 1331 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | 1366 | + | proxyWithMiddleware := otelhttp.NewHandler( |
| 1367 | + | middleware.Chain(proxyMiddlewares...)(svc.proxySvc), | |||
| 1368 | + | "gordon.proxy", | |||
| 1369 | + | ) | |||
| 1332 | proxyMux := http.NewServeMux() | 1370 | proxyMux := http.NewServeMux() | ||
| 1333 | proxyMux.Handle("/", proxyWithMiddleware) | 1371 | proxyMux.Handle("/", proxyWithMiddleware) | ||
| 1334 | 1372 | ||||
| @@ -1360,7 +1398,11 @@ func buildRegistryHandlerWithMiddleware( | |||||
| 1360 | 1398 | ||||
| 1361 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | 1399 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | ||
| 1362 | 1400 | ||||
| 1363 | - | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | 1401 | + | registryWithOtel := otelhttp.NewHandler( |
| 1402 | + | middleware.Chain(registryMiddlewares...)(registryHandler), | |||
| 1403 | + | "gordon.registry", | |||
| 1404 | + | ) | |||
| 1405 | + | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |||
| 1364 | } | 1406 | } | ||
| 1365 | 1407 | ||||
| 1366 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1408 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| @@ -2219,6 +2261,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2219 | v.SetDefault("images.prune.enabled", false) | 2261 | v.SetDefault("images.prune.enabled", false) | ||
| 2220 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | 2262 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | ||
| 2221 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | 2263 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | ||
| 2264 | + | v.SetDefault("telemetry.enabled", false) | |||
| 2265 | + | v.SetDefault("telemetry.endpoint", "") | |||
| 2266 | + | v.SetDefault("telemetry.auth_token", "") | |||
| 2267 | + | v.SetDefault("telemetry.traces", true) | |||
| 2268 | + | v.SetDefault("telemetry.metrics", true) | |||
| 2269 | + | v.SetDefault("telemetry.logs", true) | |||
| 2270 | + | v.SetDefault("telemetry.trace_sample_rate", 1.0) | |||
| 2271 | + | ||||
| 2222 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 2272 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 2223 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2273 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 2224 | v.SetDefault("deploy.readiness_delay", "5s") | 2274 | v.SetDefault("deploy.readiness_delay", "5s") | ||
M internal/usecase/container/monitor.go +17 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/zerowrap" | |
| 9 | + | "go.opentelemetry.io/otel/attribute" | |
| 10 | + | "go.opentelemetry.io/otel/metric" | |
| 9 | 11 | ||
| 10 | 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 | 201 | Int("consecutive", rec.consecutive). | |
| 200 | 202 | Dur("backoff", backoff). | |
| 201 | 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 | 210 | return | |
| 203 | 211 | } | |
| 204 | 212 | m.mu.Unlock() | |
| @@ -216,6 +224,15 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||
| 216 | 224 | Int("exit_code", exitCode). | |
| 217 | 225 | Msg("monitor: restarting crashed container") | |
| 218 | 226 | ||
| 227 | + | // Record restart metric | |
| 228 | + | if m.service.metrics != nil { | |
| 229 | + | attrs := metric.WithAttributes( | |
| 230 | + | attribute.String("domain", domainName), | |
| 231 | + | attribute.String("source", "monitor"), | |
| 232 | + | ) | |
| 233 | + | m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs) | |
| 234 | + | } | |
| 235 | + | ||
| 219 | 236 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | |
| 220 | 237 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | |
| 221 | 238 | } | |
| @@ -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 | return | 210 | return | ||
| 203 | } | 211 | } | ||
| 204 | m.mu.Unlock() | 212 | m.mu.Unlock() | ||
| @@ -216,6 +224,15 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||||
| 216 | Int("exit_code", exitCode). | 224 | Int("exit_code", exitCode). | ||
| 217 | Msg("monitor: restarting crashed container") | 225 | Msg("monitor: restarting crashed container") | ||
| 218 | 226 | ||||
| 227 | + | // Record restart metric | |||
| 228 | + | if m.service.metrics != nil { | |||
| 229 | + | attrs := metric.WithAttributes( | |||
| 230 | + | attribute.String("domain", domainName), | |||
| 231 | + | attribute.String("source", "monitor"), | |||
| 232 | + | ) | |||
| 233 | + | m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 219 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | 236 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | ||
| 220 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | 237 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | ||
| 221 | } | 238 | } | ||
M internal/usecase/container/service.go +73 -0
| @@ -12,7 +12,13 @@ import ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 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 | 22 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | 23 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 24 | ) | |
| @@ -44,6 +50,8 @@ type Config struct { | |||
| 44 | 50 | DrainTimeout time.Duration // Max wait for in-flight requests to drain | |
| 45 | 51 | } | |
| 46 | 52 | ||
| 53 | + | var tracer = otel.Tracer("gordon.container") | |
| 54 | + | ||
| 47 | 55 | const ( | |
| 48 | 56 | PullPolicyAlways = "always" | |
| 49 | 57 | PullPolicyIfNotPresent = "if-not-present" | |
| @@ -65,6 +73,7 @@ type Service struct { | |||
| 65 | 73 | cacheInvalidator out.ProxyCacheInvalidator | |
| 66 | 74 | drainWaiter out.ProxyDrainWaiter | |
| 67 | 75 | config Config | |
| 76 | + | metrics *telemetry.Metrics | |
| 68 | 77 | containers map[string]*domain.Container | |
| 69 | 78 | attachments map[string][]string // ownerDomain → []containerIDs | |
| 70 | 79 | 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 | 152 | // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous | |
| 139 | 153 | // cache invalidation during zero-downtime deployments. This must be called | |
| 140 | 154 | // after construction because the proxy service is created after the container | |
| @@ -216,6 +230,15 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||
| 216 | 230 | // Deploy creates and starts a container for the given route. | |
| 217 | 231 | // Implements zero-downtime deployment: new container starts before old one stops. | |
| 218 | 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 | 242 | // Serialize deploys for the same domain to prevent race conditions | |
| 220 | 243 | // (e.g. multiple image.pushed events + explicit deploy call from CLI). | |
| 221 | 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 | 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 | 278 | existing, hasExisting := s.getTrackedContainer(route.Domain) | |
| 236 | 279 | ||
| 237 | 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 | 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 | 418 | log := zerowrap.FromCtx(ctx) | |
| 373 | 419 | ||
| 374 | 420 | containerConfig := s.buildContainerConfig( | |
| @@ -408,9 +454,17 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 408 | 454 | ||
| 409 | 455 | func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool { | |
| 410 | 456 | s.mu.Lock() | |
| 457 | + | _, wasTracked := s.containers[domainName] | |
| 411 | 458 | s.containers[domainName] = container | |
| 412 | 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 | 468 | s.publishContainerDeployed(ctx, domainName, container.ID) | |
| 415 | 469 | ||
| 416 | 470 | s.mu.RLock() | |
| @@ -539,6 +593,14 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||
| 539 | 593 | } | |
| 540 | 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 | 604 | // Restart attachments if requested | |
| 543 | 605 | if withAttachments && len(attachmentIDs) > 0 { | |
| 544 | 606 | for _, attachID := range attachmentIDs { | |
| @@ -626,6 +688,13 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er | |||
| 626 | 688 | } | |
| 627 | 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 | 698 | // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition: | |
| 630 | 699 | // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock, | |
| 631 | 700 | // 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 | 1230 | // - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests | |
| 1162 | 1231 | // - For tagged images, returns the original imageRef after tagging the pulled image | |
| 1163 | 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 | 1237 | ctx = zerowrap.CtxWithField(ctx, "image", imageRef) | |
| 1165 | 1238 | log := zerowrap.FromCtx(ctx) | |
| 1166 | 1239 | ||
| @@ -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 drain | 50 | 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.ProxyCacheInvalidator | 73 | cacheInvalidator out.ProxyCacheInvalidator | ||
| 66 | drainWaiter out.ProxyDrainWaiter | 74 | drainWaiter out.ProxyDrainWaiter | ||
| 67 | config Config | 75 | config Config | ||
| 76 | + | metrics *telemetry.Metrics | |||
| 68 | containers map[string]*domain.Container | 77 | containers map[string]*domain.Container | ||
| 69 | attachments map[string][]string // ownerDomain → []containerIDs | 78 | attachments map[string][]string // ownerDomain → []containerIDs | ||
| 70 | mu sync.RWMutex | 79 | 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 synchronous | 152 | // SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous | ||
| 139 | // cache invalidation during zero-downtime deployments. This must be called | 153 | // cache invalidation during zero-downtime deployments. This must be called | ||
| 140 | // after construction because the proxy service is created after the container | 154 | // 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 conditions | 242 | // 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] = container | 458 | 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 requested | 604 | // 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 concurrent | 700 | // 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 digests | 1230 | // - 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 image | 1231 | // - 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 +9 -0
| @@ -18,12 +18,17 @@ import ( | |||
| 18 | 18 | "time" | |
| 19 | 19 | ||
| 20 | 20 | "github.com/bnema/zerowrap" | |
| 21 | + | "go.opentelemetry.io/otel" | |
| 22 | + | "go.opentelemetry.io/otel/attribute" | |
| 23 | + | "go.opentelemetry.io/otel/trace" | |
| 21 | 24 | ||
| 22 | 25 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 23 | 26 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | 27 | "github.com/bnema/gordon/internal/domain" | |
| 25 | 28 | ) | |
| 26 | 29 | ||
| 30 | + | var proxyTracer = otel.Tracer("gordon.proxy") | |
| 31 | + | ||
| 27 | 32 | // proxyTransport is a shared HTTP transport for proxying to application containers. | |
| 28 | 33 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | |
| 29 | 34 | var proxyTransport = &http.Transport{ | |
| @@ -156,6 +161,10 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 156 | 161 | ||
| 157 | 162 | // GetTarget returns the proxy target for a given domain. | |
| 158 | 163 | func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) { | |
| 164 | + | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | |
| 165 | + | trace.WithAttributes(attribute.String("domain", domainName))) | |
| 166 | + | defer span.End() | |
| 167 | + | ||
| 159 | 168 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 160 | 169 | zerowrap.FieldLayer: "usecase", | |
| 161 | 170 | zerowrap.FieldUseCase: "GetTarget", | |
| @@ -18,12 +18,17 @@ 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/trace" | |||
| 21 | 24 | ||||
| 22 | "github.com/bnema/gordon/internal/boundaries/in" | 25 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 23 | "github.com/bnema/gordon/internal/boundaries/out" | 26 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 24 | "github.com/bnema/gordon/internal/domain" | 27 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | ) | 28 | ) | ||
| 26 | 29 | ||||
| 30 | + | var proxyTracer = otel.Tracer("gordon.proxy") | |||
| 31 | + | ||||
| 27 | // proxyTransport is a shared HTTP transport for proxying to application containers. | 32 | // proxyTransport is a shared HTTP transport for proxying to application containers. | ||
| 28 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | 33 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | ||
| 29 | var proxyTransport = &http.Transport{ | 34 | var proxyTransport = &http.Transport{ | ||
| @@ -156,6 +161,10 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 156 | 161 | ||||
| 157 | // GetTarget returns the proxy target for a given domain. | 162 | // GetTarget returns the proxy target for a given domain. | ||
| 158 | func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) { | 163 | func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) { | ||
| 164 | + | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | |||
| 165 | + | trace.WithAttributes(attribute.String("domain", domainName))) | |||
| 166 | + | defer span.End() | |||
| 167 | + | ||||
| 159 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 168 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 160 | zerowrap.FieldLayer: "usecase", | 169 | zerowrap.FieldLayer: "usecase", | ||
| 161 | zerowrap.FieldUseCase: "GetTarget", | 170 | zerowrap.FieldUseCase: "GetTarget", | ||
M internal/usecase/registry/service.go +31 -0
| @@ -9,17 +9,30 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | 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 | 18 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | 19 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 20 | "github.com/bnema/gordon/pkg/validation" | |
| 16 | 21 | ) | |
| 17 | 22 | ||
| 23 | + | var registryTracer = otel.Tracer("gordon.registry") | |
| 24 | + | ||
| 18 | 25 | // Service implements the RegistryService interface. | |
| 19 | 26 | type Service struct { | |
| 20 | 27 | blobStorage out.BlobStorage | |
| 21 | 28 | manifestStorage out.ManifestStorage | |
| 22 | 29 | 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 | 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 | 74 | // PutManifest stores a manifest and returns the calculated digest. | |
| 62 | 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 | 84 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 64 | 85 | zerowrap.FieldLayer: "usecase", | |
| 65 | 86 | zerowrap.FieldUseCase: "PutManifest", | |
| @@ -75,6 +96,16 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 75 | 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 | 109 | // Publish image pushed event only for tag references (not digests). | |
| 79 | 110 | // A docker push sends manifests by both digest and tag; firing only on | |
| 80 | 111 | // 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.BlobStorage | 27 | blobStorage out.BlobStorage | ||
| 21 | manifestStorage out.ManifestStorage | 28 | manifestStorage out.ManifestStorage | ||
| 22 | eventBus out.EventPublisher | 29 | 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 on | 110 | // 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. | ||