Merge pull request #72 from bnema/feat/otel-observability
feat(telemetry): add OpenTelemetry instrumentation for traces, metrics, and logs
17 files · +950 -95
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 | + | ```text | |
| 53 | + | http://localhost:5080/api/default | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | This produces export URLs like `http://localhost:5080/api/default/v1/traces`. | |
| 57 | + | ||
| 58 | + | ## Authentication | |
| 59 | + | ||
| 60 | + | Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it as a `Basic` authorization header on every OTLP export request. | |
| 61 | + | ||
| 62 | + | For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI. | |
| 63 | + | ||
| 64 | + | Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods: | |
| 65 | + | ||
| 66 | + | | Method | How | | |
| 67 | + | |--------|-----| | |
| 68 | + | | Environment variable | `GORDON_TELEMETRY_AUTH_TOKEN=<token> gordon serve` | | |
| 69 | + | | Plain text in config | Set `auth_token = "<token>"` (restrict file permissions) | | |
| 70 | + | ||
| 71 | + | ## Trace Sampling | |
| 72 | + | ||
| 73 | + | | Value | Behavior | | |
| 74 | + | |-------|----------| | |
| 75 | + | | `1.0` | Sample every trace (default) | | |
| 76 | + | | `0.5` | Sample 50% of traces | | |
| 77 | + | | `0.0` | Drop all traces (tracing pipeline stays initialized) | | |
| 78 | + | ||
| 79 | + | At low traffic volumes (< 1000 req/s), keep the rate at `1.0`. Reduce it if storage costs or export bandwidth become a concern. | |
| 80 | + | ||
| 81 | + | ## Metrics Reference | |
| 82 | + | ||
| 83 | + | Gordon exports these custom metrics (all prefixed with `gordon.`): | |
| 84 | + | ||
| 85 | + | ### Deployments | |
| 86 | + | ||
| 87 | + | | Metric | Type | Unit | Description | | |
| 88 | + | |--------|------|------|-------------| | |
| 89 | + | | `gordon.deploy.total` | Counter | - | Total deployments attempted | | |
| 90 | + | | `gordon.deploy.duration_seconds` | Histogram | s | Time from deploy start to completion | | |
| 91 | + | | `gordon.deploy.errors` | Counter | - | Deployments that failed | | |
| 92 | + | ||
| 93 | + | Attributes: `domain`, `image` | |
| 94 | + | ||
| 95 | + | ### Container Lifecycle | |
| 96 | + | ||
| 97 | + | | Metric | Type | Unit | Description | | |
| 98 | + | |--------|------|------|-------------| | |
| 99 | + | | `gordon.container.restarts` | Counter | - | Container restart count | | |
| 100 | + | | `gordon.container.crash_loops` | Counter | - | Crash loop detections | | |
| 101 | + | | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers | | |
| 102 | + | ||
| 103 | + | Attributes: `domain`, `source` (restarts only: `monitor` or `api`) | |
| 104 | + | ||
| 105 | + | ### Registry | |
| 106 | + | ||
| 107 | + | | Metric | Type | Unit | Description | | |
| 108 | + | |--------|------|------|-------------| | |
| 109 | + | | `gordon.registry.push.total` | Counter | - | Manifest pushes received | | |
| 110 | + | | `gordon.registry.push.bytes` | Counter | By | Total manifest bytes pushed | | |
| 111 | + | ||
| 112 | + | Attributes: `name`, `reference` | |
| 113 | + | ||
| 114 | + | ### Event Bus | |
| 115 | + | ||
| 116 | + | | Metric | Type | Unit | Description | | |
| 117 | + | |--------|------|------|-------------| | |
| 118 | + | | `gordon.events.processed` | Counter | - | Events handled successfully | | |
| 119 | + | | `gordon.events.dropped` | Counter | - | Events dropped (channel full) | | |
| 120 | + | ||
| 121 | + | Attributes: `event_type` | |
| 122 | + | ||
| 123 | + | ### HTTP (via otelhttp) | |
| 124 | + | ||
| 125 | + | The `otelhttp` middleware automatically records standard HTTP server metrics on both the proxy and registry servers: | |
| 126 | + | ||
| 127 | + | - `http.server.request.duration` | |
| 128 | + | - `http.server.request.body.size` | |
| 129 | + | - `http.server.response.body.size` | |
| 130 | + | ||
| 131 | + | ## Trace Spans | |
| 132 | + | ||
| 133 | + | | Span Name | Package | Description | | |
| 134 | + | |-----------|---------|-------------| | |
| 135 | + | | `container.deploy` | container | Full deploy lifecycle (root span) | | |
| 136 | + | | `container.create_and_start` | container | Container creation, start, and readiness | | |
| 137 | + | | `container.ensure_image` | container | Image pull and tagging | | |
| 138 | + | | `registry.put_manifest` | registry | Manifest storage and event publish | | |
| 139 | + | | `proxy.get_target` | proxy | Proxy target resolution and caching | | |
| 140 | + | ||
| 141 | + | The `otelhttp` middleware adds an HTTP-level span to every request on both servers. | |
| 142 | + | ||
| 143 | + | ## Examples | |
| 144 | + | ||
| 145 | + | ### OpenObserve (self-hosted) | |
| 146 | + | ||
| 147 | + | ```toml | |
| 148 | + | [telemetry] | |
| 149 | + | enabled = true | |
| 150 | + | endpoint = "http://localhost:5080/api/default" | |
| 151 | + | auth_token = "YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0" | |
| 152 | + | traces = true | |
| 153 | + | metrics = true | |
| 154 | + | logs = true | |
| 155 | + | trace_sample_rate = 1.0 | |
| 156 | + | ``` | |
| 157 | + | ||
| 158 | + | ### Grafana Cloud (OTLP) | |
| 159 | + | ||
| 160 | + | ```toml | |
| 161 | + | [telemetry] | |
| 162 | + | enabled = true | |
| 163 | + | endpoint = "https://otlp-gateway-prod-us-east-0.grafana.net/otlp" | |
| 164 | + | auth_token = "<instance-id>:<api-key>" | |
| 165 | + | traces = true | |
| 166 | + | metrics = true | |
| 167 | + | logs = true | |
| 168 | + | trace_sample_rate = 0.1 | |
| 169 | + | ``` | |
| 170 | + | ||
| 171 | + | ### Traces Only (minimal overhead) | |
| 172 | + | ||
| 173 | + | ```toml | |
| 174 | + | [telemetry] | |
| 175 | + | enabled = true | |
| 176 | + | endpoint = "http://localhost:4318" | |
| 177 | + | traces = true | |
| 178 | + | metrics = false | |
| 179 | + | logs = false | |
| 180 | + | trace_sample_rate = 0.5 | |
| 181 | + | ``` | |
| 182 | + | ||
| 183 | + | ## Environment Variable Override | |
| 184 | + | ||
| 185 | + | ```bash | |
| 186 | + | GORDON_TELEMETRY_ENABLED=true | |
| 187 | + | GORDON_TELEMETRY_ENDPOINT=http://localhost:5080/api/default | |
| 188 | + | GORDON_TELEMETRY_AUTH_TOKEN=YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0 | |
| 189 | + | GORDON_TELEMETRY_TRACES=true | |
| 190 | + | GORDON_TELEMETRY_METRICS=true | |
| 191 | + | GORDON_TELEMETRY_LOGS=true | |
| 192 | + | GORDON_TELEMETRY_TRACE_SAMPLE_RATE=1.0 | |
| 193 | + | ``` | |
| 194 | + | ||
| 195 | + | ## Related | |
| 196 | + | ||
| 197 | + | - [Configuration Overview](./index.md) | |
| 198 | + | - [Logging](./logging.md) | |
| 199 | + | - [Configuration Reference](./reference.md) | |
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | # Telemetry Configuration | |||
| 2 | + | ||||
| 3 | + | Export traces, metrics, and logs from Gordon via OpenTelemetry (OTLP/HTTP). | |||
| 4 | + | ||||
| 5 | + | When enabled, Gordon ships all three observability signals to an OTLP-compatible backend such as OpenObserve, Jaeger, or Grafana Cloud. Existing zerolog output continues unchanged; the OTel log bridge runs alongside it. | |||
| 6 | + | ||||
| 7 | + | ## Configuration | |||
| 8 | + | ||||
| 9 | + | ```toml | |||
| 10 | + | [telemetry] | |||
| 11 | + | enabled = false | |||
| 12 | + | endpoint = "" | |||
| 13 | + | auth_token = "" | |||
| 14 | + | traces = true | |||
| 15 | + | metrics = true | |||
| 16 | + | logs = true | |||
| 17 | + | trace_sample_rate = 1.0 | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | ## Options | |||
| 21 | + | ||||
| 22 | + | | Option | Type | Default | Description | | |||
| 23 | + | |--------|------|---------|-------------| | |||
| 24 | + | | `enabled` | bool | `false` | Enable OTLP telemetry export | | |||
| 25 | + | | `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) | | |||
| 26 | + | | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth | | |||
| 27 | + | | `traces` | bool | `true` | Export distributed traces | | |||
| 28 | + | | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) | | |||
| 29 | + | | `logs` | bool | `true` | Bridge zerolog output to OTLP logs | | |||
| 30 | + | | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) | | |||
| 31 | + | ||||
| 32 | + | ## How It Works | |||
| 33 | + | ||||
| 34 | + | Gordon initializes an OTel provider at startup. When telemetry is enabled: | |||
| 35 | + | ||||
| 36 | + | 1. **Traces** -- Spans wrap critical operations: container deploy, image pull, registry manifest push, and proxy target resolution. The `otelhttp` middleware adds a span to every HTTP request on both the proxy and registry servers. | |||
| 37 | + | 2. **Metrics** -- Gordon records custom counters and histograms for deploys, container restarts, crash loops, managed container count, registry pushes, and event bus throughput. | |||
| 38 | + | 3. **Logs** -- A zerowrap/otel hook bridges all structured log output to the OTLP log pipeline. Every log line automatically carries `trace_id` and `span_id` when emitted inside a traced request. | |||
| 39 | + | ||||
| 40 | + | When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead. | |||
| 41 | + | ||||
| 42 | + | ## Endpoint URL | |||
| 43 | + | ||||
| 44 | + | The `endpoint` field accepts a full URL. Gordon parses it to extract: | |||
| 45 | + | ||||
| 46 | + | - **Host and port** for the OTLP exporter | |||
| 47 | + | - **Base path** appended with `/v1/traces`, `/v1/metrics`, or `/v1/logs` | |||
| 48 | + | - **Scheme** -- `http://` disables TLS; `https://` enables it | |||
| 49 | + | ||||
| 50 | + | For OpenObserve, the endpoint typically includes the organization path: | |||
| 51 | + | ||||
| 52 | + | ```text | |||
| 53 | + | http://localhost:5080/api/default | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | This produces export URLs like `http://localhost:5080/api/default/v1/traces`. | |||
| 57 | + | ||||
| 58 | + | ## Authentication | |||
| 59 | + | ||||
| 60 | + | Set `auth_token` to the Base64-encoded `user:password` string. Gordon sends it as a `Basic` authorization header on every OTLP export request. | |||
| 61 | + | ||||
| 62 | + | For OpenObserve, copy the token from **Ingestion > OTLP** in the web UI. | |||
| 63 | + | ||||
| 64 | + | Since Gordon itself is the platform (not a managed container), it does not use `gordon secrets set`. Store the token with one of these methods: | |||
| 65 | + | ||||
| 66 | + | | Method | How | | |||
| 67 | + | |--------|-----| | |||
| 68 | + | | Environment variable | `GORDON_TELEMETRY_AUTH_TOKEN=<token> gordon serve` | | |||
| 69 | + | | Plain text in config | Set `auth_token = "<token>"` (restrict file permissions) | | |||
| 70 | + | ||||
| 71 | + | ## Trace Sampling | |||
| 72 | + | ||||
| 73 | + | | Value | Behavior | | |||
| 74 | + | |-------|----------| | |||
| 75 | + | | `1.0` | Sample every trace (default) | | |||
| 76 | + | | `0.5` | Sample 50% of traces | | |||
| 77 | + | | `0.0` | Drop all traces (tracing pipeline stays initialized) | | |||
| 78 | + | ||||
| 79 | + | At low traffic volumes (< 1000 req/s), keep the rate at `1.0`. Reduce it if storage costs or export bandwidth become a concern. | |||
| 80 | + | ||||
| 81 | + | ## Metrics Reference | |||
| 82 | + | ||||
| 83 | + | Gordon exports these custom metrics (all prefixed with `gordon.`): | |||
| 84 | + | ||||
| 85 | + | ### Deployments | |||
| 86 | + | ||||
| 87 | + | | Metric | Type | Unit | Description | | |||
| 88 | + | |--------|------|------|-------------| | |||
| 89 | + | | `gordon.deploy.total` | Counter | - | Total deployments attempted | | |||
| 90 | + | | `gordon.deploy.duration_seconds` | Histogram | s | Time from deploy start to completion | | |||
| 91 | + | | `gordon.deploy.errors` | Counter | - | Deployments that failed | | |||
| 92 | + | ||||
| 93 | + | Attributes: `domain`, `image` | |||
| 94 | + | ||||
| 95 | + | ### Container Lifecycle | |||
| 96 | + | ||||
| 97 | + | | Metric | Type | Unit | Description | | |||
| 98 | + | |--------|------|------|-------------| | |||
| 99 | + | | `gordon.container.restarts` | Counter | - | Container restart count | | |||
| 100 | + | | `gordon.container.crash_loops` | Counter | - | Crash loop detections | | |||
| 101 | + | | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers | | |||
| 102 | + | ||||
| 103 | + | Attributes: `domain`, `source` (restarts only: `monitor` or `api`) | |||
| 104 | + | ||||
| 105 | + | ### Registry | |||
| 106 | + | ||||
| 107 | + | | Metric | Type | Unit | Description | | |||
| 108 | + | |--------|------|------|-------------| | |||
| 109 | + | | `gordon.registry.push.total` | Counter | - | Manifest pushes received | | |||
| 110 | + | | `gordon.registry.push.bytes` | Counter | By | Total manifest bytes pushed | | |||
| 111 | + | ||||
| 112 | + | Attributes: `name`, `reference` | |||
| 113 | + | ||||
| 114 | + | ### Event Bus | |||
| 115 | + | ||||
| 116 | + | | Metric | Type | Unit | Description | | |||
| 117 | + | |--------|------|------|-------------| | |||
| 118 | + | | `gordon.events.processed` | Counter | - | Events handled successfully | | |||
| 119 | + | | `gordon.events.dropped` | Counter | - | Events dropped (channel full) | | |||
| 120 | + | ||||
| 121 | + | Attributes: `event_type` | |||
| 122 | + | ||||
| 123 | + | ### HTTP (via otelhttp) | |||
| 124 | + | ||||
| 125 | + | The `otelhttp` middleware automatically records standard HTTP server metrics on both the proxy and registry servers: | |||
| 126 | + | ||||
| 127 | + | - `http.server.request.duration` | |||
| 128 | + | - `http.server.request.body.size` | |||
| 129 | + | - `http.server.response.body.size` | |||
| 130 | + | ||||
| 131 | + | ## Trace Spans | |||
| 132 | + | ||||
| 133 | + | | Span Name | Package | Description | | |||
| 134 | + | |-----------|---------|-------------| | |||
| 135 | + | | `container.deploy` | container | Full deploy lifecycle (root span) | | |||
| 136 | + | | `container.create_and_start` | container | Container creation, start, and readiness | | |||
| 137 | + | | `container.ensure_image` | container | Image pull and tagging | | |||
| 138 | + | | `registry.put_manifest` | registry | Manifest storage and event publish | | |||
| 139 | + | | `proxy.get_target` | proxy | Proxy target resolution and caching | | |||
| 140 | + | ||||
| 141 | + | The `otelhttp` middleware adds an HTTP-level span to every request on both servers. | |||
| 142 | + | ||||
| 143 | + | ## Examples | |||
| 144 | + | ||||
| 145 | + | ### OpenObserve (self-hosted) | |||
| 146 | + | ||||
| 147 | + | ```toml | |||
| 148 | + | [telemetry] | |||
| 149 | + | enabled = true | |||
| 150 | + | endpoint = "http://localhost:5080/api/default" | |||
| 151 | + | auth_token = "YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0" | |||
| 152 | + | traces = true | |||
| 153 | + | metrics = true | |||
| 154 | + | logs = true | |||
| 155 | + | trace_sample_rate = 1.0 | |||
| 156 | + | ``` | |||
| 157 | + | ||||
| 158 | + | ### Grafana Cloud (OTLP) | |||
| 159 | + | ||||
| 160 | + | ```toml | |||
| 161 | + | [telemetry] | |||
| 162 | + | enabled = true | |||
| 163 | + | endpoint = "https://otlp-gateway-prod-us-east-0.grafana.net/otlp" | |||
| 164 | + | auth_token = "<instance-id>:<api-key>" | |||
| 165 | + | traces = true | |||
| 166 | + | metrics = true | |||
| 167 | + | logs = true | |||
| 168 | + | trace_sample_rate = 0.1 | |||
| 169 | + | ``` | |||
| 170 | + | ||||
| 171 | + | ### Traces Only (minimal overhead) | |||
| 172 | + | ||||
| 173 | + | ```toml | |||
| 174 | + | [telemetry] | |||
| 175 | + | enabled = true | |||
| 176 | + | endpoint = "http://localhost:4318" | |||
| 177 | + | traces = true | |||
| 178 | + | metrics = false | |||
| 179 | + | logs = false | |||
| 180 | + | trace_sample_rate = 0.5 | |||
| 181 | + | ``` | |||
| 182 | + | ||||
| 183 | + | ## Environment Variable Override | |||
| 184 | + | ||||
| 185 | + | ```bash | |||
| 186 | + | GORDON_TELEMETRY_ENABLED=true | |||
| 187 | + | GORDON_TELEMETRY_ENDPOINT=http://localhost:5080/api/default | |||
| 188 | + | GORDON_TELEMETRY_AUTH_TOKEN=YWRtaW5AZXhhbXBsZS5jb206c2VjcmV0 | |||
| 189 | + | GORDON_TELEMETRY_TRACES=true | |||
| 190 | + | GORDON_TELEMETRY_METRICS=true | |||
| 191 | + | GORDON_TELEMETRY_LOGS=true | |||
| 192 | + | GORDON_TELEMETRY_TRACE_SAMPLE_RATE=1.0 | |||
| 193 | + | ``` | |||
| 194 | + | ||||
| 195 | + | ## Related | |||
| 196 | + | ||||
| 197 | + | - [Configuration Overview](./index.md) | |||
| 198 | + | - [Logging](./logging.md) | |||
| 199 | + | - [Configuration Reference](./reference.md) | |||
M docs/reference/index.md +1 -0
| @@ -6,6 +6,7 @@ Technical reference documentation for Gordon. | |||
| 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](../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](../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 -6
| @@ -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,12 +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.54.0 // indirect | |
| 85 | - | go.opentelemetry.io/otel v1.39.0 // indirect | |
| 86 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect | |
| 87 | - | go.opentelemetry.io/otel/metric v1.39.0 // indirect | |
| 88 | - | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | |
| 89 | - | go.opentelemetry.io/otel/trace v1.39.0 // indirect | |
| 99 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 | |
| 100 | + | go.opentelemetry.io/otel v1.40.0 | |
| 101 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 | |
| 102 | + | go.opentelemetry.io/otel/metric v1.40.0 | |
| 103 | + | go.opentelemetry.io/otel/trace v1.40.0 | |
| 90 | 104 | golang.org/x/sys v0.40.0 // indirect | |
| 91 | 105 | golang.org/x/text v0.33.0 // indirect | |
| 92 | 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,12 +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.54.0 // indirect | 99 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 |
| 85 | - | go.opentelemetry.io/otel v1.39.0 // indirect | 100 | + | go.opentelemetry.io/otel v1.40.0 |
| 86 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect | 101 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 |
| 87 | - | go.opentelemetry.io/otel/metric v1.39.0 // indirect | 102 | + | go.opentelemetry.io/otel/metric v1.40.0 |
| 88 | - | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | 103 | + | go.opentelemetry.io/otel/trace v1.40.0 |
| 89 | - | go.opentelemetry.io/otel/trace v1.39.0 // indirect | |||
| 90 | golang.org/x/sys v0.40.0 // indirect | 104 | golang.org/x/sys v0.40.0 // indirect | ||
| 91 | golang.org/x/text v0.33.0 // indirect | 105 | golang.org/x/text v0.33.0 // indirect | ||
| 92 | gopkg.in/yaml.v3 v3.0.1 // indirect | 106 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
M go.sum +48 -30
| @@ -10,8 +10,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v | |||
| 10 | 10 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | |
| 11 | 11 | github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU= | |
| 12 | 12 | github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s= | |
| 13 | - | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= | |
| 14 | - | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= | |
| 13 | + | github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= | |
| 14 | + | github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= | |
| 15 | 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | |
| 16 | 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | |
| 17 | 17 | github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= | |
| @@ -72,12 +72,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||
| 72 | 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= | |
| 78 | 80 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 79 | - | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= | |
| 80 | - | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= | |
| 81 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= | |
| 82 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= | |
| 81 | 83 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 82 | 84 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 83 | 85 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | |
| @@ -161,22 +163,36 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM | |||
| 161 | 163 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | |
| 162 | 164 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | |
| 163 | 165 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= | |
| 164 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= | |
| 165 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= | |
| 166 | - | go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= | |
| 167 | - | go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= | |
| 168 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= | |
| 169 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= | |
| 170 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= | |
| 171 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= | |
| 172 | - | go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= | |
| 173 | - | go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= | |
| 174 | - | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= | |
| 175 | - | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= | |
| 176 | - | go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= | |
| 177 | - | go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= | |
| 178 | - | go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= | |
| 179 | - | go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= | |
| 166 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= | |
| 167 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= | |
| 168 | + | go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= | |
| 169 | + | go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= | |
| 170 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= | |
| 171 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= | |
| 172 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= | |
| 173 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= | |
| 174 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= | |
| 175 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= | |
| 176 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= | |
| 177 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= | |
| 178 | + | go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= | |
| 179 | + | go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= | |
| 180 | + | go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= | |
| 181 | + | go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= | |
| 182 | + | go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= | |
| 183 | + | go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= | |
| 184 | + | go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= | |
| 185 | + | go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= | |
| 186 | + | go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= | |
| 187 | + | go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= | |
| 188 | + | go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= | |
| 189 | + | go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= | |
| 190 | + | go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= | |
| 191 | + | go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= | |
| 192 | + | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= | |
| 193 | + | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | |
| 194 | + | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | |
| 195 | + | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | |
| 180 | 196 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | |
| 181 | 197 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | |
| 182 | 198 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| @@ -187,8 +203,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM | |||
| 187 | 203 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | |
| 188 | 204 | golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= | |
| 189 | 205 | golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= | |
| 190 | - | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= | |
| 191 | - | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= | |
| 206 | + | golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= | |
| 207 | + | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= | |
| 192 | 208 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | |
| 193 | 209 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 194 | 210 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| @@ -203,14 +219,16 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |||
| 203 | 219 | golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= | |
| 204 | 220 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | |
| 205 | 221 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= | |
| 206 | - | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= | |
| 207 | - | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= | |
| 208 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= | |
| 209 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= | |
| 210 | - | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= | |
| 211 | - | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= | |
| 212 | - | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= | |
| 213 | - | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= | |
| 222 | + | gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= | |
| 223 | + | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= | |
| 224 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= | |
| 225 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= | |
| 226 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= | |
| 227 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= | |
| 228 | + | google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= | |
| 229 | + | google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= | |
| 230 | + | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | |
| 231 | + | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= | |
| 214 | 232 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 215 | 233 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | |
| 216 | 234 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | |
| @@ -10,8 +10,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v | |||||
| 10 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | 10 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | ||
| 11 | github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU= | 11 | github.com/bnema/zerowrap v1.3.0 h1:NHrO6d34rbFOmbLSO3n1TbqA/g5djgVfTGmCoVMOlDU= | ||
| 12 | github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s= | 12 | github.com/bnema/zerowrap v1.3.0/go.mod h1:30FCqzwS7FNTNH1GC+ScGC99GITOzOaCYW266L4QF5s= | ||
| 13 | - | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= | 13 | + | github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= |
| 14 | - | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= | 14 | + | github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= |
| 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | ||
| 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||
| 17 | github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= | 17 | github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= | ||
| @@ -72,12 +72,14 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd | |||||
| 72 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | 72 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||
| 73 | github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= | 73 | github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= | ||
| 74 | github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | 74 | github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | ||
| 75 | + | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | |||
| 76 | + | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | |||
| 75 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | 77 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||
| 76 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | 78 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||
| 77 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | 79 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| 78 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | 80 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 79 | - | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= | 81 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= |
| 80 | - | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= | 82 | + | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= |
| 81 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | 83 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||
| 82 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | 84 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||
| 83 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | 85 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | ||
| @@ -161,22 +163,36 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM | |||||
| 161 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | 163 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | ||
| 162 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | 164 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | ||
| 163 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= | 165 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= | ||
| 164 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= | 166 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= |
| 165 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= | 167 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= |
| 166 | - | go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= | 168 | + | go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= |
| 167 | - | go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= | 169 | + | go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= |
| 168 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= | 170 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= |
| 169 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= | 171 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= |
| 170 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= | 172 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= |
| 171 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= | 173 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= |
| 172 | - | go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= | 174 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= |
| 173 | - | go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= | 175 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= |
| 174 | - | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= | 176 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= |
| 175 | - | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= | 177 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= |
| 176 | - | go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= | 178 | + | go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= |
| 177 | - | go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= | 179 | + | go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= |
| 178 | - | go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= | 180 | + | go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= |
| 179 | - | go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= | 181 | + | go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= |
| 182 | + | go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= | |||
| 183 | + | go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= | |||
| 184 | + | go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= | |||
| 185 | + | go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= | |||
| 186 | + | go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= | |||
| 187 | + | go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= | |||
| 188 | + | go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= | |||
| 189 | + | go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= | |||
| 190 | + | go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= | |||
| 191 | + | go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= | |||
| 192 | + | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= | |||
| 193 | + | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | |||
| 194 | + | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | |||
| 195 | + | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | |||
| 180 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | 196 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | ||
| 181 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | 197 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | ||
| 182 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | 198 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||
| @@ -187,8 +203,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM | |||||
| 187 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | 203 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | ||
| 188 | golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= | 204 | golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= | ||
| 189 | golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= | 205 | golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= | ||
| 190 | - | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= | 206 | + | golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= |
| 191 | - | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= | 207 | + | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= |
| 192 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | 208 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
| 193 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | 209 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 194 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 210 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| @@ -203,14 +219,16 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= | |||||
| 203 | golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= | 219 | golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= | ||
| 204 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | 220 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= | ||
| 205 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= | 221 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= | ||
| 206 | - | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= | 222 | + | gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= |
| 207 | - | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= | 223 | + | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= |
| 208 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= | 224 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= |
| 209 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= | 225 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= |
| 210 | - | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= | 226 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= |
| 211 | - | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= | 227 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= |
| 212 | - | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= | 228 | + | google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= |
| 213 | - | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= | 229 | + | google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= |
| 230 | + | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | |||
| 231 | + | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= | |||
| 214 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | 232 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 215 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | 233 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||
| 216 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | 234 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||
M internal/adapters/in/http/middleware/logging.go +15 -4
| @@ -12,6 +12,7 @@ import ( | |||
| 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 | |
| @@ -105,8 +116,8 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||
| 105 | 116 | // Calculate duration | |
| 106 | 117 | duration := time.Since(start) | |
| 107 | 118 | ||
| 108 | - | // Log the request | |
| 109 | - | log.Info(). | |
| 119 | + | // Log the request (use reqLog to include trace_id/span_id) | |
| 120 | + | reqLog.Info(). | |
| 110 | 121 | Str(zerowrap.FieldLayer, "adapter"). | |
| 111 | 122 | Str(zerowrap.FieldAdapter, "http"). | |
| 112 | 123 | Str("request_id", requestID). | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 15 | + | "go.opentelemetry.io/otel/trace" | |||
| 15 | 16 | ||||
| 16 | "github.com/bnema/gordon/internal/adapters/dto" | 17 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 17 | ) | 18 | ) | ||
| @@ -95,8 +96,18 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 95 | // IP spoofing via X-Forwarded-For from untrusted sources. | 96 | // IP spoofing via X-Forwarded-For from untrusted sources. | ||
| 96 | clientIP := GetClientIP(r, nets) | 97 | clientIP := GetClientIP(r, nets) | ||
| 97 | 98 | ||||
| 98 | - | // Attach the logger to the request context for downstream 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 | ||
| @@ -105,8 +116,8 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H | |||||
| 105 | // Calculate duration | 116 | // Calculate duration | ||
| 106 | duration := time.Since(start) | 117 | duration := time.Since(start) | ||
| 107 | 118 | ||||
| 108 | - | // Log the request | 119 | + | // Log the request (use reqLog to include trace_id/span_id) |
| 109 | - | log.Info(). | 120 | + | reqLog.Info(). |
| 110 | Str(zerowrap.FieldLayer, "adapter"). | 121 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 111 | Str(zerowrap.FieldAdapter, "http"). | 122 | Str(zerowrap.FieldAdapter, "http"). | ||
| 112 | Str("request_id", requestID). | 123 | Str("request_id", requestID). | ||
M internal/adapters/out/eventbus/inmemory.go +26 -0
| @@ -9,7 +9,10 @@ import ( | |||
| 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,15 @@ 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 | + | // Must be called before Start() to avoid data races on bus.metrics reads. | |
| 35 | + | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | |
| 36 | + | bus.mu.Lock() | |
| 37 | + | bus.metrics = m | |
| 38 | + | bus.mu.Unlock() | |
| 27 | 39 | } | |
| 28 | 40 | ||
| 29 | 41 | // NewInMemory creates a new in-memory event bus. | |
| @@ -83,6 +95,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||
| 83 | 95 | Str("image_name", event.ImageName). | |
| 84 | 96 | Str("tag", event.Tag). | |
| 85 | 97 | Msg("event channel is full, dropping event after 5s timeout") | |
| 98 | + | ||
| 99 | + | // Record dropped event metric | |
| 100 | + | if bus.metrics != nil { | |
| 101 | + | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | |
| 102 | + | attribute.String("event_type", string(event.Type)), | |
| 103 | + | )) | |
| 104 | + | } | |
| 86 | 105 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | |
| 87 | 106 | } | |
| 88 | 107 | } | |
| @@ -217,6 +236,13 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||
| 217 | 236 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | |
| 218 | 237 | Dur(zerowrap.FieldDuration, time.Since(start)). | |
| 219 | 238 | Msg("event handled successfully") | |
| 239 | + | ||
| 240 | + | // Record processed event metric | |
| 241 | + | if bus.metrics != nil { | |
| 242 | + | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | |
| 243 | + | attribute.String("event_type", string(event.Type)), | |
| 244 | + | )) | |
| 245 | + | } | |
| 220 | 246 | } | |
| 221 | 247 | case <-ctx.Done(): | |
| 222 | 248 | cancel() | |
| @@ -9,7 +9,10 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 11 | "github.com/google/uuid" | 11 | "github.com/google/uuid" | ||
| 12 | + | "go.opentelemetry.io/otel/attribute" | |||
| 13 | + | "go.opentelemetry.io/otel/metric" | |||
| 12 | 14 | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |||
| 13 | "github.com/bnema/gordon/internal/boundaries/out" | 16 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 14 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | ) | 18 | ) | ||
| @@ -24,6 +27,15 @@ type InMemory struct { | |||||
| 24 | cancel context.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 | + | // Must be called before Start() to avoid data races on bus.metrics reads. | |||
| 35 | + | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | |||
| 36 | + | bus.mu.Lock() | |||
| 37 | + | bus.metrics = m | |||
| 38 | + | bus.mu.Unlock() | |||
| 27 | } | 39 | } | ||
| 28 | 40 | ||||
| 29 | // NewInMemory creates a new in-memory event bus. | 41 | // NewInMemory creates a new in-memory event bus. | ||
| @@ -83,6 +95,13 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||||
| 83 | Str("image_name", event.ImageName). | 95 | Str("image_name", event.ImageName). | ||
| 84 | Str("tag", event.Tag). | 96 | Str("tag", event.Tag). | ||
| 85 | Msg("event channel is full, dropping event after 5s timeout") | 97 | Msg("event channel is full, dropping event after 5s timeout") | ||
| 98 | + | ||||
| 99 | + | // Record dropped event metric | |||
| 100 | + | if bus.metrics != nil { | |||
| 101 | + | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | |||
| 102 | + | attribute.String("event_type", string(event.Type)), | |||
| 103 | + | )) | |||
| 104 | + | } | |||
| 86 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | 105 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | ||
| 87 | } | 106 | } | ||
| 88 | } | 107 | } | ||
| @@ -217,6 +236,13 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||||
| 217 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | 236 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", h)). | ||
| 218 | Dur(zerowrap.FieldDuration, time.Since(start)). | 237 | Dur(zerowrap.FieldDuration, time.Since(start)). | ||
| 219 | Msg("event handled successfully") | 238 | Msg("event handled successfully") | ||
| 239 | + | ||||
| 240 | + | // Record processed event metric | |||
| 241 | + | if bus.metrics != nil { | |||
| 242 | + | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | |||
| 243 | + | attribute.String("event_type", string(event.Type)), | |||
| 244 | + | )) | |||
| 245 | + | } | |||
| 220 | } | 246 | } | ||
| 221 | case <-ctx.Done(): | 247 | case <-ctx.Done(): | ||
| 222 | cancel() | 248 | cancel() | ||
A internal/adapters/out/telemetry/metrics.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package telemetry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "go.opentelemetry.io/otel" | |
| 5 | + | "go.opentelemetry.io/otel/metric" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Metrics holds Gordon-specific OTel metrics instruments. | |
| 9 | + | type Metrics struct { | |
| 10 | + | // Deployments | |
| 11 | + | DeployTotal metric.Int64Counter | |
| 12 | + | DeployDuration metric.Float64Histogram | |
| 13 | + | DeployErrors metric.Int64Counter | |
| 14 | + | ||
| 15 | + | // Container lifecycle | |
| 16 | + | ContainerRestarts metric.Int64Counter | |
| 17 | + | ContainerCrashLoops metric.Int64Counter | |
| 18 | + | ManagedContainers metric.Int64UpDownCounter | |
| 19 | + | ||
| 20 | + | // Registry | |
| 21 | + | ImagePushTotal metric.Int64Counter | |
| 22 | + | ImagePushSize metric.Int64Counter // bytes | |
| 23 | + | ||
| 24 | + | // Events | |
| 25 | + | EventsProcessed metric.Int64Counter | |
| 26 | + | EventsDropped metric.Int64Counter | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewMetrics creates and registers all Gordon metric instruments. | |
| 30 | + | // Returns a noop-safe Metrics struct — all fields are always initialized | |
| 31 | + | // (OTel returns noop instruments when no MeterProvider is set). | |
| 32 | + | func NewMetrics() (*Metrics, error) { | |
| 33 | + | meter := otel.Meter("gordon") | |
| 34 | + | m := &Metrics{} | |
| 35 | + | var err error | |
| 36 | + | ||
| 37 | + | if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total", | |
| 38 | + | metric.WithDescription("Total number of deployments")); err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | if m.DeployDuration, err = meter.Float64Histogram("gordon.deploy.duration_seconds", | |
| 42 | + | metric.WithDescription("Deploy duration in seconds"), | |
| 43 | + | metric.WithUnit("s"), | |
| 44 | + | metric.WithExplicitBucketBoundaries(1, 5, 10, 30, 60, 120, 300)); err != nil { | |
| 45 | + | return nil, err | |
| 46 | + | } | |
| 47 | + | if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors", | |
| 48 | + | metric.WithDescription("Total deploy errors")); err != nil { | |
| 49 | + | return nil, err | |
| 50 | + | } | |
| 51 | + | if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts", | |
| 52 | + | metric.WithDescription("Total container restarts")); err != nil { | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops", | |
| 56 | + | metric.WithDescription("Total crash loop detections")); err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed", | |
| 60 | + | metric.WithDescription("Currently managed containers")); err != nil { | |
| 61 | + | return nil, err | |
| 62 | + | } | |
| 63 | + | if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total", | |
| 64 | + | metric.WithDescription("Total image pushes")); err != nil { | |
| 65 | + | return nil, err | |
| 66 | + | } | |
| 67 | + | if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes", | |
| 68 | + | metric.WithDescription("Total bytes pushed to registry"), | |
| 69 | + | metric.WithUnit("By")); err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed", | |
| 73 | + | metric.WithDescription("Total events processed")); err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped", | |
| 77 | + | metric.WithDescription("Total events dropped")); err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return m, nil | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package telemetry | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "go.opentelemetry.io/otel" | |||
| 5 | + | "go.opentelemetry.io/otel/metric" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Metrics holds Gordon-specific OTel metrics instruments. | |||
| 9 | + | type Metrics struct { | |||
| 10 | + | // Deployments | |||
| 11 | + | DeployTotal metric.Int64Counter | |||
| 12 | + | DeployDuration metric.Float64Histogram | |||
| 13 | + | DeployErrors metric.Int64Counter | |||
| 14 | + | ||||
| 15 | + | // Container lifecycle | |||
| 16 | + | ContainerRestarts metric.Int64Counter | |||
| 17 | + | ContainerCrashLoops metric.Int64Counter | |||
| 18 | + | ManagedContainers metric.Int64UpDownCounter | |||
| 19 | + | ||||
| 20 | + | // Registry | |||
| 21 | + | ImagePushTotal metric.Int64Counter | |||
| 22 | + | ImagePushSize metric.Int64Counter // bytes | |||
| 23 | + | ||||
| 24 | + | // Events | |||
| 25 | + | EventsProcessed metric.Int64Counter | |||
| 26 | + | EventsDropped metric.Int64Counter | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewMetrics creates and registers all Gordon metric instruments. | |||
| 30 | + | // Returns a noop-safe Metrics struct — all fields are always initialized | |||
| 31 | + | // (OTel returns noop instruments when no MeterProvider is set). | |||
| 32 | + | func NewMetrics() (*Metrics, error) { | |||
| 33 | + | meter := otel.Meter("gordon") | |||
| 34 | + | m := &Metrics{} | |||
| 35 | + | var err error | |||
| 36 | + | ||||
| 37 | + | if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total", | |||
| 38 | + | metric.WithDescription("Total number of deployments")); err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | if m.DeployDuration, err = meter.Float64Histogram("gordon.deploy.duration_seconds", | |||
| 42 | + | metric.WithDescription("Deploy duration in seconds"), | |||
| 43 | + | metric.WithUnit("s"), | |||
| 44 | + | metric.WithExplicitBucketBoundaries(1, 5, 10, 30, 60, 120, 300)); err != nil { | |||
| 45 | + | return nil, err | |||
| 46 | + | } | |||
| 47 | + | if m.DeployErrors, err = meter.Int64Counter("gordon.deploy.errors", | |||
| 48 | + | metric.WithDescription("Total deploy errors")); err != nil { | |||
| 49 | + | return nil, err | |||
| 50 | + | } | |||
| 51 | + | if m.ContainerRestarts, err = meter.Int64Counter("gordon.container.restarts", | |||
| 52 | + | metric.WithDescription("Total container restarts")); err != nil { | |||
| 53 | + | return nil, err | |||
| 54 | + | } | |||
| 55 | + | if m.ContainerCrashLoops, err = meter.Int64Counter("gordon.container.crash_loops", | |||
| 56 | + | metric.WithDescription("Total crash loop detections")); err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | if m.ManagedContainers, err = meter.Int64UpDownCounter("gordon.container.managed", | |||
| 60 | + | metric.WithDescription("Currently managed containers")); err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | if m.ImagePushTotal, err = meter.Int64Counter("gordon.registry.push.total", | |||
| 64 | + | metric.WithDescription("Total image pushes")); err != nil { | |||
| 65 | + | return nil, err | |||
| 66 | + | } | |||
| 67 | + | if m.ImagePushSize, err = meter.Int64Counter("gordon.registry.push.bytes", | |||
| 68 | + | metric.WithDescription("Total bytes pushed to registry"), | |||
| 69 | + | metric.WithUnit("By")); err != nil { | |||
| 70 | + | return nil, err | |||
| 71 | + | } | |||
| 72 | + | if m.EventsProcessed, err = meter.Int64Counter("gordon.events.processed", | |||
| 73 | + | metric.WithDescription("Total events processed")); err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | if m.EventsDropped, err = meter.Int64Counter("gordon.events.dropped", | |||
| 77 | + | metric.WithDescription("Total events dropped")); err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return m, nil | |||
| 82 | + | } | |||
A internal/adapters/out/telemetry/provider.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | // Package telemetry provides OpenTelemetry initialization for Gordon. | |
| 2 | + | // It configures trace, metric, and log providers that export via OTLP/HTTP. | |
| 3 | + | package telemetry | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "net/url" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "go.opentelemetry.io/otel" | |
| 12 | + | "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" | |
| 13 | + | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" | |
| 14 | + | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" | |
| 15 | + | otellog "go.opentelemetry.io/otel/sdk/log" | |
| 16 | + | "go.opentelemetry.io/otel/sdk/metric" | |
| 17 | + | "go.opentelemetry.io/otel/sdk/resource" | |
| 18 | + | "go.opentelemetry.io/otel/sdk/trace" | |
| 19 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Config holds telemetry configuration. | |
| 23 | + | type Config struct { | |
| 24 | + | Enabled bool `mapstructure:"enabled"` | |
| 25 | + | Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318" | |
| 26 | + | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | |
| 27 | + | Traces bool `mapstructure:"traces"` // Enable trace export | |
| 28 | + | Metrics bool `mapstructure:"metrics"` // Enable metric export | |
| 29 | + | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | |
| 30 | + | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Provider holds the initialized OTel providers. | |
| 34 | + | type Provider struct { | |
| 35 | + | TracerProvider *trace.TracerProvider | |
| 36 | + | MeterProvider *metric.MeterProvider | |
| 37 | + | LogProvider *otellog.LoggerProvider | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // endpointConfig holds parsed endpoint details shared across exporter setup. | |
| 41 | + | type endpointConfig struct { | |
| 42 | + | host string | |
| 43 | + | basePath string | |
| 44 | + | insecure bool | |
| 45 | + | headers map[string]string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // NewProvider creates and configures OTel providers based on the given config. | |
| 49 | + | // Returns a noop provider if telemetry is disabled. | |
| 50 | + | // The returned shutdown function must be called on application exit. | |
| 51 | + | func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) { | |
| 52 | + | noop := func(context.Context) {} | |
| 53 | + | ||
| 54 | + | if !cfg.Enabled || cfg.Endpoint == "" { | |
| 55 | + | return &Provider{}, noop, nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | res, err := resource.New(ctx, | |
| 59 | + | resource.WithAttributes( | |
| 60 | + | semconv.ServiceName(serviceName), | |
| 61 | + | semconv.ServiceVersion(version), | |
| 62 | + | ), | |
| 63 | + | resource.WithOS(), | |
| 64 | + | resource.WithHost(), | |
| 65 | + | ) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, noop, fmt.Errorf("create resource: %w", err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | ep, err := parseEndpoint(cfg) | |
| 71 | + | if err != nil { | |
| 72 | + | return nil, noop, err | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | var shutdowns []func(context.Context) error | |
| 76 | + | p := &Provider{} | |
| 77 | + | ||
| 78 | + | if cfg.Traces { | |
| 79 | + | if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil { | |
| 80 | + | return nil, noop, err | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if cfg.Metrics { | |
| 85 | + | if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil { | |
| 86 | + | return nil, noop, err | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if cfg.Logs { | |
| 91 | + | if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil { | |
| 92 | + | return nil, noop, err | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | shutdown := func(ctx context.Context) { | |
| 97 | + | for _, fn := range shutdowns { | |
| 98 | + | _ = fn(ctx) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | return p, shutdown, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // parseEndpoint extracts host, path, and scheme from the configured endpoint URL. | |
| 106 | + | func parseEndpoint(cfg Config) (*endpointConfig, error) { | |
| 107 | + | parsedURL, err := url.Parse(cfg.Endpoint) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, fmt.Errorf("parse endpoint URL: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | headers := make(map[string]string) | |
| 113 | + | if cfg.AuthToken != "" { | |
| 114 | + | headers["Authorization"] = "Basic " + cfg.AuthToken | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | return &endpointConfig{ | |
| 118 | + | host: parsedURL.Host, | |
| 119 | + | basePath: strings.TrimSuffix(parsedURL.Path, "/"), | |
| 120 | + | insecure: parsedURL.Scheme == "http", | |
| 121 | + | headers: headers, | |
| 122 | + | }, nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // setupTracing initializes the trace provider with an OTLP/HTTP exporter. | |
| 126 | + | func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 127 | + | traceOpts := []otlptracehttp.Option{ | |
| 128 | + | otlptracehttp.WithEndpoint(ep.host), | |
| 129 | + | otlptracehttp.WithHeaders(ep.headers), | |
| 130 | + | } | |
| 131 | + | if ep.basePath != "" { | |
| 132 | + | traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces")) | |
| 133 | + | } | |
| 134 | + | if ep.insecure { | |
| 135 | + | traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | traceExp, err := otlptracehttp.New(ctx, traceOpts...) | |
| 139 | + | if err != nil { | |
| 140 | + | return fmt.Errorf("create trace exporter: %w", err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample. | |
| 144 | + | // Clamp to [0, 1] so out-of-range values don't produce surprising behavior. | |
| 145 | + | rate := cfg.TraceSampleRate | |
| 146 | + | if rate < 0 { | |
| 147 | + | rate = 0 | |
| 148 | + | } else if rate > 1 { | |
| 149 | + | rate = 1 | |
| 150 | + | } | |
| 151 | + | sampler := trace.AlwaysSample() | |
| 152 | + | if rate == 0 { | |
| 153 | + | sampler = trace.NeverSample() | |
| 154 | + | } else if rate < 1.0 { | |
| 155 | + | sampler = trace.TraceIDRatioBased(rate) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | tp := trace.NewTracerProvider( | |
| 159 | + | trace.WithBatcher(traceExp), | |
| 160 | + | trace.WithResource(res), | |
| 161 | + | trace.WithSampler(sampler), | |
| 162 | + | ) | |
| 163 | + | otel.SetTracerProvider(tp) | |
| 164 | + | p.TracerProvider = tp | |
| 165 | + | *shutdowns = append(*shutdowns, tp.Shutdown) | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // setupMetrics initializes the meter provider with an OTLP/HTTP exporter. | |
| 170 | + | func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 171 | + | metricOpts := []otlpmetrichttp.Option{ | |
| 172 | + | otlpmetrichttp.WithEndpoint(ep.host), | |
| 173 | + | otlpmetrichttp.WithHeaders(ep.headers), | |
| 174 | + | } | |
| 175 | + | if ep.basePath != "" { | |
| 176 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics")) | |
| 177 | + | } | |
| 178 | + | if ep.insecure { | |
| 179 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) | |
| 183 | + | if err != nil { | |
| 184 | + | return fmt.Errorf("create metric exporter: %w", err) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | mp := metric.NewMeterProvider( | |
| 188 | + | metric.WithReader(metric.NewPeriodicReader(metricExp)), | |
| 189 | + | metric.WithResource(res), | |
| 190 | + | ) | |
| 191 | + | otel.SetMeterProvider(mp) | |
| 192 | + | p.MeterProvider = mp | |
| 193 | + | *shutdowns = append(*shutdowns, mp.Shutdown) | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // setupLogging initializes the log provider with an OTLP/HTTP exporter. | |
| 198 | + | func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |
| 199 | + | logOpts := []otlploghttp.Option{ | |
| 200 | + | otlploghttp.WithEndpoint(ep.host), | |
| 201 | + | otlploghttp.WithHeaders(ep.headers), | |
| 202 | + | } | |
| 203 | + | if ep.basePath != "" { | |
| 204 | + | logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs")) | |
| 205 | + | } | |
| 206 | + | if ep.insecure { | |
| 207 | + | logOpts = append(logOpts, otlploghttp.WithInsecure()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | logExp, err := otlploghttp.New(ctx, logOpts...) | |
| 211 | + | if err != nil { | |
| 212 | + | return fmt.Errorf("create log exporter: %w", err) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | lp := otellog.NewLoggerProvider( | |
| 216 | + | otellog.WithProcessor(otellog.NewBatchProcessor(logExp)), | |
| 217 | + | otellog.WithResource(res), | |
| 218 | + | ) | |
| 219 | + | p.LogProvider = lp | |
| 220 | + | *shutdowns = append(*shutdowns, lp.Shutdown) | |
| 221 | + | return nil | |
| 222 | + | } | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | // Package telemetry provides OpenTelemetry initialization for Gordon. | |||
| 2 | + | // It configures trace, metric, and log providers that export via OTLP/HTTP. | |||
| 3 | + | package telemetry | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "net/url" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "go.opentelemetry.io/otel" | |||
| 12 | + | "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" | |||
| 13 | + | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" | |||
| 14 | + | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" | |||
| 15 | + | otellog "go.opentelemetry.io/otel/sdk/log" | |||
| 16 | + | "go.opentelemetry.io/otel/sdk/metric" | |||
| 17 | + | "go.opentelemetry.io/otel/sdk/resource" | |||
| 18 | + | "go.opentelemetry.io/otel/sdk/trace" | |||
| 19 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Config holds telemetry configuration. | |||
| 23 | + | type Config struct { | |||
| 24 | + | Enabled bool `mapstructure:"enabled"` | |||
| 25 | + | Endpoint string `mapstructure:"endpoint"` // OTLP HTTP endpoint, e.g. "http://localhost:4318" | |||
| 26 | + | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | |||
| 27 | + | Traces bool `mapstructure:"traces"` // Enable trace export | |||
| 28 | + | Metrics bool `mapstructure:"metrics"` // Enable metric export | |||
| 29 | + | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | |||
| 30 | + | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Provider holds the initialized OTel providers. | |||
| 34 | + | type Provider struct { | |||
| 35 | + | TracerProvider *trace.TracerProvider | |||
| 36 | + | MeterProvider *metric.MeterProvider | |||
| 37 | + | LogProvider *otellog.LoggerProvider | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // endpointConfig holds parsed endpoint details shared across exporter setup. | |||
| 41 | + | type endpointConfig struct { | |||
| 42 | + | host string | |||
| 43 | + | basePath string | |||
| 44 | + | insecure bool | |||
| 45 | + | headers map[string]string | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // NewProvider creates and configures OTel providers based on the given config. | |||
| 49 | + | // Returns a noop provider if telemetry is disabled. | |||
| 50 | + | // The returned shutdown function must be called on application exit. | |||
| 51 | + | func NewProvider(ctx context.Context, cfg Config, serviceName, version string) (*Provider, func(context.Context), error) { | |||
| 52 | + | noop := func(context.Context) {} | |||
| 53 | + | ||||
| 54 | + | if !cfg.Enabled || cfg.Endpoint == "" { | |||
| 55 | + | return &Provider{}, noop, nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | res, err := resource.New(ctx, | |||
| 59 | + | resource.WithAttributes( | |||
| 60 | + | semconv.ServiceName(serviceName), | |||
| 61 | + | semconv.ServiceVersion(version), | |||
| 62 | + | ), | |||
| 63 | + | resource.WithOS(), | |||
| 64 | + | resource.WithHost(), | |||
| 65 | + | ) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, noop, fmt.Errorf("create resource: %w", err) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | ep, err := parseEndpoint(cfg) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return nil, noop, err | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | var shutdowns []func(context.Context) error | |||
| 76 | + | p := &Provider{} | |||
| 77 | + | ||||
| 78 | + | if cfg.Traces { | |||
| 79 | + | if err := p.setupTracing(ctx, ep, cfg, res, &shutdowns); err != nil { | |||
| 80 | + | return nil, noop, err | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if cfg.Metrics { | |||
| 85 | + | if err := p.setupMetrics(ctx, ep, res, &shutdowns); err != nil { | |||
| 86 | + | return nil, noop, err | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | if cfg.Logs { | |||
| 91 | + | if err := p.setupLogging(ctx, ep, res, &shutdowns); err != nil { | |||
| 92 | + | return nil, noop, err | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | shutdown := func(ctx context.Context) { | |||
| 97 | + | for _, fn := range shutdowns { | |||
| 98 | + | _ = fn(ctx) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | return p, shutdown, nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // parseEndpoint extracts host, path, and scheme from the configured endpoint URL. | |||
| 106 | + | func parseEndpoint(cfg Config) (*endpointConfig, error) { | |||
| 107 | + | parsedURL, err := url.Parse(cfg.Endpoint) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return nil, fmt.Errorf("parse endpoint URL: %w", err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | headers := make(map[string]string) | |||
| 113 | + | if cfg.AuthToken != "" { | |||
| 114 | + | headers["Authorization"] = "Basic " + cfg.AuthToken | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | return &endpointConfig{ | |||
| 118 | + | host: parsedURL.Host, | |||
| 119 | + | basePath: strings.TrimSuffix(parsedURL.Path, "/"), | |||
| 120 | + | insecure: parsedURL.Scheme == "http", | |||
| 121 | + | headers: headers, | |||
| 122 | + | }, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // setupTracing initializes the trace provider with an OTLP/HTTP exporter. | |||
| 126 | + | func (p *Provider) setupTracing(ctx context.Context, ep *endpointConfig, cfg Config, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 127 | + | traceOpts := []otlptracehttp.Option{ | |||
| 128 | + | otlptracehttp.WithEndpoint(ep.host), | |||
| 129 | + | otlptracehttp.WithHeaders(ep.headers), | |||
| 130 | + | } | |||
| 131 | + | if ep.basePath != "" { | |||
| 132 | + | traceOpts = append(traceOpts, otlptracehttp.WithURLPath(ep.basePath+"/v1/traces")) | |||
| 133 | + | } | |||
| 134 | + | if ep.insecure { | |||
| 135 | + | traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | traceExp, err := otlptracehttp.New(ctx, traceOpts...) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return fmt.Errorf("create trace exporter: %w", err) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // 0 = never sample (disabled), (0,1) = ratio-based, >= 1 = always sample. | |||
| 144 | + | // Clamp to [0, 1] so out-of-range values don't produce surprising behavior. | |||
| 145 | + | rate := cfg.TraceSampleRate | |||
| 146 | + | if rate < 0 { | |||
| 147 | + | rate = 0 | |||
| 148 | + | } else if rate > 1 { | |||
| 149 | + | rate = 1 | |||
| 150 | + | } | |||
| 151 | + | sampler := trace.AlwaysSample() | |||
| 152 | + | if rate == 0 { | |||
| 153 | + | sampler = trace.NeverSample() | |||
| 154 | + | } else if rate < 1.0 { | |||
| 155 | + | sampler = trace.TraceIDRatioBased(rate) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | tp := trace.NewTracerProvider( | |||
| 159 | + | trace.WithBatcher(traceExp), | |||
| 160 | + | trace.WithResource(res), | |||
| 161 | + | trace.WithSampler(sampler), | |||
| 162 | + | ) | |||
| 163 | + | otel.SetTracerProvider(tp) | |||
| 164 | + | p.TracerProvider = tp | |||
| 165 | + | *shutdowns = append(*shutdowns, tp.Shutdown) | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // setupMetrics initializes the meter provider with an OTLP/HTTP exporter. | |||
| 170 | + | func (p *Provider) setupMetrics(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 171 | + | metricOpts := []otlpmetrichttp.Option{ | |||
| 172 | + | otlpmetrichttp.WithEndpoint(ep.host), | |||
| 173 | + | otlpmetrichttp.WithHeaders(ep.headers), | |||
| 174 | + | } | |||
| 175 | + | if ep.basePath != "" { | |||
| 176 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithURLPath(ep.basePath+"/v1/metrics")) | |||
| 177 | + | } | |||
| 178 | + | if ep.insecure { | |||
| 179 | + | metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) | |||
| 183 | + | if err != nil { | |||
| 184 | + | return fmt.Errorf("create metric exporter: %w", err) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | mp := metric.NewMeterProvider( | |||
| 188 | + | metric.WithReader(metric.NewPeriodicReader(metricExp)), | |||
| 189 | + | metric.WithResource(res), | |||
| 190 | + | ) | |||
| 191 | + | otel.SetMeterProvider(mp) | |||
| 192 | + | p.MeterProvider = mp | |||
| 193 | + | *shutdowns = append(*shutdowns, mp.Shutdown) | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // setupLogging initializes the log provider with an OTLP/HTTP exporter. | |||
| 198 | + | func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *resource.Resource, shutdowns *[]func(context.Context) error) error { | |||
| 199 | + | logOpts := []otlploghttp.Option{ | |||
| 200 | + | otlploghttp.WithEndpoint(ep.host), | |||
| 201 | + | otlploghttp.WithHeaders(ep.headers), | |||
| 202 | + | } | |||
| 203 | + | if ep.basePath != "" { | |||
| 204 | + | logOpts = append(logOpts, otlploghttp.WithURLPath(ep.basePath+"/v1/logs")) | |||
| 205 | + | } | |||
| 206 | + | if ep.insecure { | |||
| 207 | + | logOpts = append(logOpts, otlploghttp.WithInsecure()) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | logExp, err := otlploghttp.New(ctx, logOpts...) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return fmt.Errorf("create log exporter: %w", err) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | lp := otellog.NewLoggerProvider( | |||
| 216 | + | otellog.WithProcessor(otellog.NewBatchProcessor(logExp)), | |||
| 217 | + | otellog.WithResource(res), | |||
| 218 | + | ) | |||
| 219 | + | p.LogProvider = lp | |||
| 220 | + | *shutdowns = append(*shutdowns, lp.Shutdown) | |||
| 221 | + | return nil | |||
| 222 | + | } | |||
M internal/app/run.go +76 -4
| @@ -25,6 +25,7 @@ import ( | |||
| 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" | |
| @@ -53,6 +58,9 @@ import ( | |||
| 53 | 58 | // Domain | |
| 54 | 59 | "github.com/bnema/gordon/internal/domain" | |
| 55 | 60 | ||
| 61 | + | // Packages | |
| 62 | + | "github.com/bnema/gordon/pkg/version" | |
| 63 | + | ||
| 56 | 64 | // Use cases | |
| 57 | 65 | "github.com/bnema/gordon/internal/usecase/auth" | |
| 58 | 66 | "github.com/bnema/gordon/internal/usecase/backup" | |
| @@ -153,6 +161,8 @@ type Config struct { | |||
| 153 | 161 | KeepLast int `mapstructure:"keep_last"` | |
| 154 | 162 | } `mapstructure:"prune"` | |
| 155 | 163 | } `mapstructure:"images"` | |
| 164 | + | ||
| 165 | + | Telemetry telemetry.Config `mapstructure:"telemetry"` | |
| 156 | 166 | } | |
| 157 | 167 | ||
| 158 | 168 | // services holds all the services used by the application. | |
| @@ -201,6 +211,29 @@ func Run(ctx context.Context, configPath string) error { | |||
| 201 | 211 | } | |
| 202 | 212 | ||
| 203 | 213 | ctx = zerowrap.WithCtx(ctx, log) | |
| 214 | + | ||
| 215 | + | // Initialize OpenTelemetry | |
| 216 | + | telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version()) | |
| 217 | + | if err != nil { | |
| 218 | + | log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it") | |
| 219 | + | } else { | |
| 220 | + | // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing. | |
| 221 | + | defer func() { | |
| 222 | + | shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 223 | + | defer cancel() | |
| 224 | + | telShutdown(shutdownCtx) | |
| 225 | + | }() | |
| 226 | + | if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" { | |
| 227 | + | // Bridge zerowrap logs to OTel if log export is enabled | |
| 228 | + | if cfg.Telemetry.Logs && telProvider.LogProvider != nil { | |
| 229 | + | otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon") | |
| 230 | + | log = zerowrap.WithHook(log, otelHook) | |
| 231 | + | ctx = zerowrap.WithCtx(ctx, log) | |
| 232 | + | } | |
| 233 | + | log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized") | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 204 | 237 | log.Info().Msg("Gordon starting") | |
| 205 | 238 | ||
| 206 | 239 | if err := ensureTLSConfig(&cfg, log); err != nil { | |
| @@ -470,6 +503,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 470 | 503 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 471 | 504 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | |
| 472 | 505 | ||
| 506 | + | injectTelemetryMetrics(cfg, svc, log) | |
| 507 | + | ||
| 473 | 508 | proxyCfg, err := buildProxyConfig(cfg, log) | |
| 474 | 509 | if err != nil { | |
| 475 | 510 | return nil, err | |
| @@ -516,6 +551,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 516 | 551 | return svc, nil | |
| 517 | 552 | } | |
| 518 | 553 | ||
| 554 | + | // injectTelemetryMetrics creates and injects OTel metrics into services when | |
| 555 | + | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | |
| 556 | + | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | |
| 557 | + | if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics { | |
| 558 | + | return | |
| 559 | + | } | |
| 560 | + | gordonMetrics, err := telemetry.NewMetrics() | |
| 561 | + | if err != nil { | |
| 562 | + | log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics") | |
| 563 | + | return | |
| 564 | + | } | |
| 565 | + | svc.containerSvc.SetMetrics(gordonMetrics) | |
| 566 | + | svc.registrySvc.SetMetrics(gordonMetrics) | |
| 567 | + | svc.eventBus.SetMetrics(gordonMetrics) | |
| 568 | + | } | |
| 569 | + | ||
| 519 | 570 | func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | |
| 520 | 571 | var err error | |
| 521 | 572 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| @@ -1328,7 +1379,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1328 | 1379 | middleware.SecurityHeaders, | |
| 1329 | 1380 | } | |
| 1330 | 1381 | ||
| 1331 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 1382 | + | proxyWithMiddleware := otelhttp.NewHandler( | |
| 1383 | + | middleware.Chain(proxyMiddlewares...)(svc.proxySvc), | |
| 1384 | + | "gordon.proxy", | |
| 1385 | + | ) | |
| 1332 | 1386 | proxyMux := http.NewServeMux() | |
| 1333 | 1387 | proxyMux.Handle("/", proxyWithMiddleware) | |
| 1334 | 1388 | ||
| @@ -1360,7 +1414,11 @@ func buildRegistryHandlerWithMiddleware( | |||
| 1360 | 1414 | ||
| 1361 | 1415 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | |
| 1362 | 1416 | ||
| 1363 | - | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1417 | + | registryWithOtel := otelhttp.NewHandler( | |
| 1418 | + | middleware.Chain(registryMiddlewares...)(registryHandler), | |
| 1419 | + | "gordon.registry", | |
| 1420 | + | ) | |
| 1421 | + | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1364 | 1422 | } | |
| 1365 | 1423 | ||
| 1366 | 1424 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| @@ -1457,7 +1515,10 @@ func registerAuthRoutes( | |||
| 1457 | 1515 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1458 | 1516 | } | |
| 1459 | 1517 | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1460 | - | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |
| 1518 | + | authWithMiddleware := otelhttp.NewHandler( | |
| 1519 | + | middleware.Chain(authMiddlewares...)(svc.authHandler), | |
| 1520 | + | "gordon.auth", | |
| 1521 | + | ) | |
| 1461 | 1522 | registryMux.Handle("/auth/", authWithMiddleware) | |
| 1462 | 1523 | } | |
| 1463 | 1524 | ||
| @@ -1502,7 +1563,10 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf | |||
| 1502 | 1563 | }) | |
| 1503 | 1564 | } | |
| 1504 | 1565 | ||
| 1505 | - | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1566 | + | adminWithMiddleware := otelhttp.NewHandler( | |
| 1567 | + | middleware.Chain(adminMiddlewares...)(svc.adminHandler), | |
| 1568 | + | "gordon.admin", | |
| 1569 | + | ) | |
| 1506 | 1570 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |
| 1507 | 1571 | proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log)) | |
| 1508 | 1572 | } | |
| @@ -2219,6 +2283,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2219 | 2283 | v.SetDefault("images.prune.enabled", false) | |
| 2220 | 2284 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | |
| 2221 | 2285 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | |
| 2286 | + | v.SetDefault("telemetry.enabled", false) | |
| 2287 | + | v.SetDefault("telemetry.endpoint", "") | |
| 2288 | + | v.SetDefault("telemetry.auth_token", "") | |
| 2289 | + | v.SetDefault("telemetry.traces", true) | |
| 2290 | + | v.SetDefault("telemetry.metrics", true) | |
| 2291 | + | v.SetDefault("telemetry.logs", true) | |
| 2292 | + | v.SetDefault("telemetry.trace_sample_rate", 1.0) | |
| 2293 | + | ||
| 2222 | 2294 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 2223 | 2295 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 2224 | 2296 | 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" | ||
| @@ -53,6 +58,9 @@ import ( | |||||
| 53 | // Domain | 58 | // Domain | ||
| 54 | "github.com/bnema/gordon/internal/domain" | 59 | "github.com/bnema/gordon/internal/domain" | ||
| 55 | 60 | ||||
| 61 | + | // Packages | |||
| 62 | + | "github.com/bnema/gordon/pkg/version" | |||
| 63 | + | ||||
| 56 | // Use cases | 64 | // Use cases | ||
| 57 | "github.com/bnema/gordon/internal/usecase/auth" | 65 | "github.com/bnema/gordon/internal/usecase/auth" | ||
| 58 | "github.com/bnema/gordon/internal/usecase/backup" | 66 | "github.com/bnema/gordon/internal/usecase/backup" | ||
| @@ -153,6 +161,8 @@ type Config struct { | |||||
| 153 | KeepLast int `mapstructure:"keep_last"` | 161 | KeepLast int `mapstructure:"keep_last"` | ||
| 154 | } `mapstructure:"prune"` | 162 | } `mapstructure:"prune"` | ||
| 155 | } `mapstructure:"images"` | 163 | } `mapstructure:"images"` | ||
| 164 | + | ||||
| 165 | + | Telemetry telemetry.Config `mapstructure:"telemetry"` | |||
| 156 | } | 166 | } | ||
| 157 | 167 | ||||
| 158 | // services holds all the services used by the application. | 168 | // services holds all the services used by the application. | ||
| @@ -201,6 +211,29 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 201 | } | 211 | } | ||
| 202 | 212 | ||||
| 203 | ctx = zerowrap.WithCtx(ctx, log) | 213 | ctx = zerowrap.WithCtx(ctx, log) | ||
| 214 | + | ||||
| 215 | + | // Initialize OpenTelemetry | |||
| 216 | + | telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version()) | |||
| 217 | + | if err != nil { | |||
| 218 | + | log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it") | |||
| 219 | + | } else { | |||
| 220 | + | // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing. | |||
| 221 | + | defer func() { | |||
| 222 | + | shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 223 | + | defer cancel() | |||
| 224 | + | telShutdown(shutdownCtx) | |||
| 225 | + | }() | |||
| 226 | + | if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" { | |||
| 227 | + | // Bridge zerowrap logs to OTel if log export is enabled | |||
| 228 | + | if cfg.Telemetry.Logs && telProvider.LogProvider != nil { | |||
| 229 | + | otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon") | |||
| 230 | + | log = zerowrap.WithHook(log, otelHook) | |||
| 231 | + | ctx = zerowrap.WithCtx(ctx, log) | |||
| 232 | + | } | |||
| 233 | + | log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized") | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 204 | log.Info().Msg("Gordon starting") | 237 | log.Info().Msg("Gordon starting") | ||
| 205 | 238 | ||||
| 206 | if err := ensureTLSConfig(&cfg, log); err != nil { | 239 | if err := ensureTLSConfig(&cfg, log); err != nil { | ||
| @@ -470,6 +503,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 470 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 503 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 471 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | 504 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | ||
| 472 | 505 | ||||
| 506 | + | injectTelemetryMetrics(cfg, svc, log) | |||
| 507 | + | ||||
| 473 | proxyCfg, err := buildProxyConfig(cfg, log) | 508 | proxyCfg, err := buildProxyConfig(cfg, log) | ||
| 474 | if err != nil { | 509 | if err != nil { | ||
| 475 | return nil, err | 510 | return nil, err | ||
| @@ -516,6 +551,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 516 | return svc, nil | 551 | return svc, nil | ||
| 517 | } | 552 | } | ||
| 518 | 553 | ||||
| 554 | + | // injectTelemetryMetrics creates and injects OTel metrics into services when | |||
| 555 | + | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | |||
| 556 | + | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | |||
| 557 | + | if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics { | |||
| 558 | + | return | |||
| 559 | + | } | |||
| 560 | + | gordonMetrics, err := telemetry.NewMetrics() | |||
| 561 | + | if err != nil { | |||
| 562 | + | log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics") | |||
| 563 | + | return | |||
| 564 | + | } | |||
| 565 | + | svc.containerSvc.SetMetrics(gordonMetrics) | |||
| 566 | + | svc.registrySvc.SetMetrics(gordonMetrics) | |||
| 567 | + | svc.eventBus.SetMetrics(gordonMetrics) | |||
| 568 | + | } | |||
| 569 | + | ||||
| 519 | func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | 570 | func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error { | ||
| 520 | var err error | 571 | var err error | ||
| 521 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | 572 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | ||
| @@ -1328,7 +1379,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1328 | middleware.SecurityHeaders, | 1379 | middleware.SecurityHeaders, | ||
| 1329 | } | 1380 | } | ||
| 1330 | 1381 | ||||
| 1331 | - | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | 1382 | + | proxyWithMiddleware := otelhttp.NewHandler( |
| 1383 | + | middleware.Chain(proxyMiddlewares...)(svc.proxySvc), | |||
| 1384 | + | "gordon.proxy", | |||
| 1385 | + | ) | |||
| 1332 | proxyMux := http.NewServeMux() | 1386 | proxyMux := http.NewServeMux() | ||
| 1333 | proxyMux.Handle("/", proxyWithMiddleware) | 1387 | proxyMux.Handle("/", proxyWithMiddleware) | ||
| 1334 | 1388 | ||||
| @@ -1360,7 +1414,11 @@ func buildRegistryHandlerWithMiddleware( | |||||
| 1360 | 1414 | ||||
| 1361 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | 1415 | appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, log) | ||
| 1362 | 1416 | ||||
| 1363 | - | return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware | 1417 | + | registryWithOtel := otelhttp.NewHandler( |
| 1418 | + | middleware.Chain(registryMiddlewares...)(registryHandler), | |||
| 1419 | + | "gordon.registry", | |||
| 1420 | + | ) | |||
| 1421 | + | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |||
| 1364 | } | 1422 | } | ||
| 1365 | 1423 | ||||
| 1366 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1424 | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| @@ -1457,7 +1515,10 @@ func registerAuthRoutes( | |||||
| 1457 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | 1515 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | ||
| 1458 | } | 1516 | } | ||
| 1459 | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | 1517 | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | ||
| 1460 | - | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | 1518 | + | authWithMiddleware := otelhttp.NewHandler( |
| 1519 | + | middleware.Chain(authMiddlewares...)(svc.authHandler), | |||
| 1520 | + | "gordon.auth", | |||
| 1521 | + | ) | |||
| 1461 | registryMux.Handle("/auth/", authWithMiddleware) | 1522 | registryMux.Handle("/auth/", authWithMiddleware) | ||
| 1462 | } | 1523 | } | ||
| 1463 | 1524 | ||||
| @@ -1502,7 +1563,10 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf | |||||
| 1502 | }) | 1563 | }) | ||
| 1503 | } | 1564 | } | ||
| 1504 | 1565 | ||||
| 1505 | - | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | 1566 | + | adminWithMiddleware := otelhttp.NewHandler( |
| 1567 | + | middleware.Chain(adminMiddlewares...)(svc.adminHandler), | |||
| 1568 | + | "gordon.admin", | |||
| 1569 | + | ) | |||
| 1506 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | 1570 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | ||
| 1507 | proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log)) | 1571 | proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log)) | ||
| 1508 | } | 1572 | } | ||
| @@ -2219,6 +2283,14 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2219 | v.SetDefault("images.prune.enabled", false) | 2283 | v.SetDefault("images.prune.enabled", false) | ||
| 2220 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | 2284 | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | ||
| 2221 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | 2285 | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | ||
| 2286 | + | v.SetDefault("telemetry.enabled", false) | |||
| 2287 | + | v.SetDefault("telemetry.endpoint", "") | |||
| 2288 | + | v.SetDefault("telemetry.auth_token", "") | |||
| 2289 | + | v.SetDefault("telemetry.traces", true) | |||
| 2290 | + | v.SetDefault("telemetry.metrics", true) | |||
| 2291 | + | v.SetDefault("telemetry.logs", true) | |||
| 2292 | + | v.SetDefault("telemetry.trace_sample_rate", 1.0) | |||
| 2293 | + | ||||
| 2222 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 2294 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 2223 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2295 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 2224 | v.SetDefault("deploy.readiness_delay", "5s") | 2296 | v.SetDefault("deploy.readiness_delay", "5s") | ||
M internal/usecase/container/monitor.go +18 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 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() | |
| @@ -218,6 +226,16 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||
| 218 | 226 | ||
| 219 | 227 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | |
| 220 | 228 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Record restart metric only on successful restart | |
| 233 | + | if m.service.metrics != nil { | |
| 234 | + | attrs := metric.WithAttributes( | |
| 235 | + | attribute.String("domain", domainName), | |
| 236 | + | attribute.String("source", "monitor"), | |
| 237 | + | ) | |
| 238 | + | m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs) | |
| 221 | 239 | } | |
| 222 | 240 | } | |
| 223 | 241 | ||
| @@ -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() | ||
| @@ -218,6 +226,16 @@ func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainNa | |||||
| 218 | 226 | ||||
| 219 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | 227 | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | ||
| 220 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | 228 | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | ||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // Record restart metric only on successful restart | |||
| 233 | + | if m.service.metrics != nil { | |||
| 234 | + | attrs := metric.WithAttributes( | |||
| 235 | + | attribute.String("domain", domainName), | |||
| 236 | + | attribute.String("source", "monitor"), | |||
| 237 | + | ) | |||
| 238 | + | m.service.metrics.ContainerRestarts.Add(ctx, 1, attrs) | |||
| 221 | } | 239 | } | ||
| 222 | } | 240 | } | ||
| 223 | 241 | ||||
M internal/usecase/container/service.go +73 -0
| @@ -12,7 +12,13 @@ import ( | |||
| 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 +71 -50
| @@ -18,12 +18,18 @@ 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/codes" | |
| 24 | + | "go.opentelemetry.io/otel/trace" | |
| 21 | 25 | ||
| 22 | 26 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 23 | 27 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | 28 | "github.com/bnema/gordon/internal/domain" | |
| 25 | 29 | ) | |
| 26 | 30 | ||
| 31 | + | var proxyTracer = otel.Tracer("gordon.proxy") | |
| 32 | + | ||
| 27 | 33 | // proxyTransport is a shared HTTP transport for proxying to application containers. | |
| 28 | 34 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | |
| 29 | 35 | var proxyTransport = &http.Transport{ | |
| @@ -155,7 +161,17 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 155 | 161 | } | |
| 156 | 162 | ||
| 157 | 163 | // GetTarget returns the proxy target for a given domain. | |
| 158 | - | func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) { | |
| 164 | + | func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) { | |
| 165 | + | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | |
| 166 | + | trace.WithAttributes(attribute.String("domain", domainName))) | |
| 167 | + | defer func() { | |
| 168 | + | if retErr != nil { | |
| 169 | + | span.RecordError(retErr) | |
| 170 | + | span.SetStatus(codes.Error, retErr.Error()) | |
| 171 | + | } | |
| 172 | + | span.End() | |
| 173 | + | }() | |
| 174 | + | ||
| 159 | 175 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 160 | 176 | zerowrap.FieldLayer: "usecase", | |
| 161 | 177 | zerowrap.FieldUseCase: "GetTarget", | |
| @@ -179,54 +195,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 179 | 195 | // Check if this is an external route | |
| 180 | 196 | externalRoutes := s.configSvc.GetExternalRoutes() | |
| 181 | 197 | if targetAddr, ok := externalRoutes[domainName]; ok { | |
| 182 | - | host, portStr, err := net.SplitHostPort(targetAddr) | |
| 183 | - | if err != nil { | |
| 184 | - | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |
| 185 | - | } | |
| 186 | - | port, err := strconv.Atoi(portStr) | |
| 187 | - | if err != nil { | |
| 188 | - | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | |
| 192 | - | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | |
| 193 | - | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |
| 194 | - | // but to a private IP when the proxy connects. | |
| 195 | - | resolvedIP, err := ResolveAndValidateHost(host) | |
| 196 | - | if err != nil { | |
| 197 | - | log.Warn(). | |
| 198 | - | Err(err). | |
| 199 | - | Str("host", host). | |
| 200 | - | Str("domain", domainName). | |
| 201 | - | Msg("SSRF protection: blocked external route to internal network") | |
| 202 | - | return nil, err | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // Preserve the original hostname for the Host header so virtual-hosted | |
| 206 | - | // upstreams work correctly. The resolved IP is used for dialing only. | |
| 207 | - | var originalHost string | |
| 208 | - | if resolvedIP != host { | |
| 209 | - | originalHost = host | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | target := &domain.ProxyTarget{ | |
| 213 | - | Host: resolvedIP, | |
| 214 | - | Port: port, | |
| 215 | - | ContainerID: "", // Not a container | |
| 216 | - | Scheme: "http", | |
| 217 | - | OriginalHost: originalHost, | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | // Cache external route target | |
| 221 | - | s.mu.Lock() | |
| 222 | - | s.targets[domainName] = target | |
| 223 | - | s.mu.Unlock() | |
| 224 | - | ||
| 225 | - | log.Debug(). | |
| 226 | - | Str("host", host). | |
| 227 | - | Int("port", port). | |
| 228 | - | Msg("using external route target") | |
| 229 | - | return target, nil | |
| 198 | + | return s.resolveExternalRoute(ctx, domainName, targetAddr, log) | |
| 230 | 199 | } | |
| 231 | 200 | ||
| 232 | 201 | // Get container for this domain | |
| @@ -238,7 +207,6 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 238 | 207 | log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain") | |
| 239 | 208 | ||
| 240 | 209 | // Build target based on runtime mode | |
| 241 | - | var target *domain.ProxyTarget | |
| 242 | 210 | ||
| 243 | 211 | if s.isRunningInContainer() { | |
| 244 | 212 | // Gordon is in a container - use container network | |
| @@ -295,6 +263,59 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 295 | 263 | return target, nil | |
| 296 | 264 | } | |
| 297 | 265 | ||
| 266 | + | // resolveExternalRoute resolves an external route target address into a ProxyTarget, | |
| 267 | + | // performing DNS validation and SSRF protection. | |
| 268 | + | func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) { | |
| 269 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |
| 270 | + | if err != nil { | |
| 271 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |
| 272 | + | } | |
| 273 | + | port, err := strconv.Atoi(portStr) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | |
| 279 | + | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | |
| 280 | + | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |
| 281 | + | // but to a private IP when the proxy connects. | |
| 282 | + | resolvedIP, err := ResolveAndValidateHost(host) | |
| 283 | + | if err != nil { | |
| 284 | + | log.Warn(). | |
| 285 | + | Err(err). | |
| 286 | + | Str("host", host). | |
| 287 | + | Str("domain", domainName). | |
| 288 | + | Msg("SSRF protection: blocked external route to internal network") | |
| 289 | + | return nil, err | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Preserve the original hostname for the Host header so virtual-hosted | |
| 293 | + | // upstreams work correctly. The resolved IP is used for dialing only. | |
| 294 | + | var originalHost string | |
| 295 | + | if resolvedIP != host { | |
| 296 | + | originalHost = host | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | t := &domain.ProxyTarget{ | |
| 300 | + | Host: resolvedIP, | |
| 301 | + | Port: port, | |
| 302 | + | ContainerID: "", // Not a container | |
| 303 | + | Scheme: "http", | |
| 304 | + | OriginalHost: originalHost, | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Cache external route target | |
| 308 | + | s.mu.Lock() | |
| 309 | + | s.targets[domainName] = t | |
| 310 | + | s.mu.Unlock() | |
| 311 | + | ||
| 312 | + | log.Debug(). | |
| 313 | + | Str("host", host). | |
| 314 | + | Int("port", port). | |
| 315 | + | Msg("using external route target") | |
| 316 | + | return t, nil | |
| 317 | + | } | |
| 318 | + | ||
| 298 | 319 | // RegisterTarget registers a new proxy target for a domain. | |
| 299 | 320 | func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error { | |
| 300 | 321 | s.mu.Lock() | |
| @@ -18,12 +18,18 @@ import ( | |||||
| 18 | "time" | 18 | "time" | ||
| 19 | 19 | ||||
| 20 | "github.com/bnema/zerowrap" | 20 | "github.com/bnema/zerowrap" | ||
| 21 | + | "go.opentelemetry.io/otel" | |||
| 22 | + | "go.opentelemetry.io/otel/attribute" | |||
| 23 | + | "go.opentelemetry.io/otel/codes" | |||
| 24 | + | "go.opentelemetry.io/otel/trace" | |||
| 21 | 25 | ||||
| 22 | "github.com/bnema/gordon/internal/boundaries/in" | 26 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 23 | "github.com/bnema/gordon/internal/boundaries/out" | 27 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 24 | "github.com/bnema/gordon/internal/domain" | 28 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | ) | 29 | ) | ||
| 26 | 30 | ||||
| 31 | + | var proxyTracer = otel.Tracer("gordon.proxy") | |||
| 32 | + | ||||
| 27 | // proxyTransport is a shared HTTP transport for proxying to application containers. | 33 | // proxyTransport is a shared HTTP transport for proxying to application containers. | ||
| 28 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | 34 | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | ||
| 29 | var proxyTransport = &http.Transport{ | 35 | var proxyTransport = &http.Transport{ | ||
| @@ -155,7 +161,17 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 155 | } | 161 | } | ||
| 156 | 162 | ||||
| 157 | // GetTarget returns the proxy target for a given domain. | 163 | // GetTarget returns the proxy target for a given domain. | ||
| 158 | - | func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) { | 164 | + | func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) { |
| 165 | + | ctx, span := proxyTracer.Start(ctx, "proxy.get_target", | |||
| 166 | + | trace.WithAttributes(attribute.String("domain", domainName))) | |||
| 167 | + | defer func() { | |||
| 168 | + | if retErr != nil { | |||
| 169 | + | span.RecordError(retErr) | |||
| 170 | + | span.SetStatus(codes.Error, retErr.Error()) | |||
| 171 | + | } | |||
| 172 | + | span.End() | |||
| 173 | + | }() | |||
| 174 | + | ||||
| 159 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 175 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 160 | zerowrap.FieldLayer: "usecase", | 176 | zerowrap.FieldLayer: "usecase", | ||
| 161 | zerowrap.FieldUseCase: "GetTarget", | 177 | zerowrap.FieldUseCase: "GetTarget", | ||
| @@ -179,54 +195,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 179 | // Check if this is an external route | 195 | // Check if this is an external route | ||
| 180 | externalRoutes := s.configSvc.GetExternalRoutes() | 196 | externalRoutes := s.configSvc.GetExternalRoutes() | ||
| 181 | if targetAddr, ok := externalRoutes[domainName]; ok { | 197 | if targetAddr, ok := externalRoutes[domainName]; ok { | ||
| 182 | - | host, portStr, err := net.SplitHostPort(targetAddr) | 198 | + | return s.resolveExternalRoute(ctx, domainName, targetAddr, log) |
| 183 | - | if err != nil { | |||
| 184 | - | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |||
| 185 | - | } | |||
| 186 | - | port, err := strconv.Atoi(portStr) | |||
| 187 | - | if err != nil { | |||
| 188 | - | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | |||
| 192 | - | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | |||
| 193 | - | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |||
| 194 | - | // but to a private IP when the proxy connects. | |||
| 195 | - | resolvedIP, err := ResolveAndValidateHost(host) | |||
| 196 | - | if err != nil { | |||
| 197 | - | log.Warn(). | |||
| 198 | - | Err(err). | |||
| 199 | - | Str("host", host). | |||
| 200 | - | Str("domain", domainName). | |||
| 201 | - | Msg("SSRF protection: blocked external route to internal network") | |||
| 202 | - | return nil, err | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | // Preserve the original hostname for the Host header so virtual-hosted | |||
| 206 | - | // upstreams work correctly. The resolved IP is used for dialing only. | |||
| 207 | - | var originalHost string | |||
| 208 | - | if resolvedIP != host { | |||
| 209 | - | originalHost = host | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | target := &domain.ProxyTarget{ | |||
| 213 | - | Host: resolvedIP, | |||
| 214 | - | Port: port, | |||
| 215 | - | ContainerID: "", // Not a container | |||
| 216 | - | Scheme: "http", | |||
| 217 | - | OriginalHost: originalHost, | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | // Cache external route target | |||
| 221 | - | s.mu.Lock() | |||
| 222 | - | s.targets[domainName] = target | |||
| 223 | - | s.mu.Unlock() | |||
| 224 | - | ||||
| 225 | - | log.Debug(). | |||
| 226 | - | Str("host", host). | |||
| 227 | - | Int("port", port). | |||
| 228 | - | Msg("using external route target") | |||
| 229 | - | return target, nil | |||
| 230 | } | 199 | } | ||
| 231 | 200 | ||||
| 232 | // Get container for this domain | 201 | // Get container for this domain | ||
| @@ -238,7 +207,6 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 238 | log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain") | 207 | log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain") | ||
| 239 | 208 | ||||
| 240 | // Build target based on runtime mode | 209 | // Build target based on runtime mode | ||
| 241 | - | var target *domain.ProxyTarget | |||
| 242 | 210 | ||||
| 243 | if s.isRunningInContainer() { | 211 | if s.isRunningInContainer() { | ||
| 244 | // Gordon is in a container - use container network | 212 | // Gordon is in a container - use container network | ||
| @@ -295,6 +263,59 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||||
| 295 | return target, nil | 263 | return target, nil | ||
| 296 | } | 264 | } | ||
| 297 | 265 | ||||
| 266 | + | // resolveExternalRoute resolves an external route target address into a ProxyTarget, | |||
| 267 | + | // performing DNS validation and SSRF protection. | |||
| 268 | + | func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) { | |||
| 269 | + | host, portStr, err := net.SplitHostPort(targetAddr) | |||
| 270 | + | if err != nil { | |||
| 271 | + | return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr}) | |||
| 272 | + | } | |||
| 273 | + | port, err := strconv.Atoi(portStr) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr}) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // SECURITY: Resolve DNS and validate that the target is not an internal/blocked | |||
| 279 | + | // network. We use the resolved IP as the proxy target to prevent DNS rebinding | |||
| 280 | + | // (TOCTOU) attacks where a hostname resolves to a public IP during validation | |||
| 281 | + | // but to a private IP when the proxy connects. | |||
| 282 | + | resolvedIP, err := ResolveAndValidateHost(host) | |||
| 283 | + | if err != nil { | |||
| 284 | + | log.Warn(). | |||
| 285 | + | Err(err). | |||
| 286 | + | Str("host", host). | |||
| 287 | + | Str("domain", domainName). | |||
| 288 | + | Msg("SSRF protection: blocked external route to internal network") | |||
| 289 | + | return nil, err | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // Preserve the original hostname for the Host header so virtual-hosted | |||
| 293 | + | // upstreams work correctly. The resolved IP is used for dialing only. | |||
| 294 | + | var originalHost string | |||
| 295 | + | if resolvedIP != host { | |||
| 296 | + | originalHost = host | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | t := &domain.ProxyTarget{ | |||
| 300 | + | Host: resolvedIP, | |||
| 301 | + | Port: port, | |||
| 302 | + | ContainerID: "", // Not a container | |||
| 303 | + | Scheme: "http", | |||
| 304 | + | OriginalHost: originalHost, | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // Cache external route target | |||
| 308 | + | s.mu.Lock() | |||
| 309 | + | s.targets[domainName] = t | |||
| 310 | + | s.mu.Unlock() | |||
| 311 | + | ||||
| 312 | + | log.Debug(). | |||
| 313 | + | Str("host", host). | |||
| 314 | + | Int("port", port). | |||
| 315 | + | Msg("using external route target") | |||
| 316 | + | return t, nil | |||
| 317 | + | } | |||
| 318 | + | ||||
| 298 | // RegisterTarget registers a new proxy target for a domain. | 319 | // RegisterTarget registers a new proxy target for a domain. | ||
| 299 | func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error { | 320 | func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error { | ||
| 300 | s.mu.Lock() | 321 | s.mu.Lock() | ||
M internal/usecase/registry/service.go +31 -0
| @@ -9,17 +9,30 @@ import ( | |||
| 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. | ||
M main.go +3 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/gordon/internal/adapters/in/cli" | |
| 7 | + | versionpkg "github.com/bnema/gordon/pkg/version" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Build information set by goreleaser via ldflags | |
| @@ -14,7 +15,8 @@ var ( | |||
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | func main() { | |
| 17 | - | // Set version information for the CLI | |
| 18 | + | // Set version information globally and for the CLI | |
| 19 | + | versionpkg.Set(version, commit, date) | |
| 18 | 20 | cli.SetVersionInfo(version, commit, date) | |
| 19 | 21 | ||
| 20 | 22 | if err := cli.NewRootCmd().Execute(); err != nil { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "os" | 4 | "os" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/gordon/internal/adapters/in/cli" | 6 | "github.com/bnema/gordon/internal/adapters/in/cli" | ||
| 7 | + | versionpkg "github.com/bnema/gordon/pkg/version" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // Build information set by goreleaser via ldflags | 10 | // Build information set by goreleaser via ldflags | ||
| @@ -14,7 +15,8 @@ var ( | |||||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | func main() { | 17 | func main() { | ||
| 17 | - | // Set version information for the CLI | 18 | + | // Set version information globally and for the CLI |
| 19 | + | versionpkg.Set(version, commit, date) | |||
| 18 | cli.SetVersionInfo(version, commit, date) | 20 | cli.SetVersionInfo(version, commit, date) | ||
| 19 | 21 | ||||
| 20 | if err := cli.NewRootCmd().Execute(); err != nil { | 22 | if err := cli.NewRootCmd().Execute(); err != nil { | ||
A pkg/version/version.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | // Package version holds build-time version info for Gordon. | |
| 2 | + | // Set via main using Set(), read from anywhere via Version(), Commit(), or BuildDate(). | |
| 3 | + | package version | |
| 4 | + | ||
| 5 | + | // Build information, populated by Set() at startup. | |
| 6 | + | var ( | |
| 7 | + | version = "dev" | |
| 8 | + | commit = "unknown" | |
| 9 | + | buildDate = "unknown" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Set stores build-time version info. Call once from main. | |
| 13 | + | func Set(v, c, d string) { | |
| 14 | + | version = v | |
| 15 | + | commit = c | |
| 16 | + | buildDate = d | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Version returns the build version string. | |
| 20 | + | func Version() string { return version } | |
| 21 | + | ||
| 22 | + | // Commit returns the build commit hash. | |
| 23 | + | func Commit() string { return commit } | |
| 24 | + | ||
| 25 | + | // BuildDate returns the build date string. | |
| 26 | + | func BuildDate() string { return buildDate } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | // Package version holds build-time version info for Gordon. | |||
| 2 | + | // Set via main using Set(), read from anywhere via Version(), Commit(), or BuildDate(). | |||
| 3 | + | package version | |||
| 4 | + | ||||
| 5 | + | // Build information, populated by Set() at startup. | |||
| 6 | + | var ( | |||
| 7 | + | version = "dev" | |||
| 8 | + | commit = "unknown" | |||
| 9 | + | buildDate = "unknown" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Set stores build-time version info. Call once from main. | |||
| 13 | + | func Set(v, c, d string) { | |||
| 14 | + | version = v | |||
| 15 | + | commit = c | |||
| 16 | + | buildDate = d | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Version returns the build version string. | |||
| 20 | + | func Version() string { return version } | |||
| 21 | + | ||||
| 22 | + | // Commit returns the build commit hash. | |||
| 23 | + | func Commit() string { return commit } | |||
| 24 | + | ||||
| 25 | + | // BuildDate returns the build date string. | |||
| 26 | + | func BuildDate() string { return buildDate } | |||