feat(telemetry): export proxy access and app container logs over OTLP (#293)
* refactor(telemetry): add Metrics port so registry and eventbus stop importing the adapter * feat(apps): add telemetry.logs opt-out at app and service level * feat(telemetry): export proxy access and app container logs over OTLP * fix(telemetry): export container output from collector start, not follower start * fix(telemetry): sanitize UTF-8, drop url.query, wrap state errors, persist resume cursors
- commit
- 99400491d9032822c811db67829e0a0a3edc1c3d
- author
- bnema · · 2026-09-25 05:43 UTC
- committer
- GitHub · · 2026-09-25 05:43 UTC
- parent
- 3e8cda9
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
29 files · +1989 -31
M .mockery.yaml +3 -0
| @@ -19,6 +19,9 @@ packages: | |||
| 19 | 19 | SecretProvider: | |
| 20 | 20 | EnvLoader: | |
| 21 | 21 | ContainerLogWriter: | |
| 22 | + | Metrics: | |
| 23 | + | LogExporter: | |
| 24 | + | ContainerLogStreamer: | |
| 22 | 25 | TokenStore: | |
| 23 | 26 | AppState: | |
| 24 | 27 | AppStateReader: | |
| @@ -19,6 +19,9 @@ packages: | |||||
| 19 | SecretProvider: | 19 | SecretProvider: | ||
| 20 | EnvLoader: | 20 | EnvLoader: | ||
| 21 | ContainerLogWriter: | 21 | ContainerLogWriter: | ||
| 22 | + | Metrics: | |||
| 23 | + | LogExporter: | |||
| 24 | + | ContainerLogStreamer: | |||
| 22 | TokenStore: | 25 | TokenStore: | ||
| 23 | AppState: | 26 | AppState: | ||
| 24 | AppStateReader: | 27 | AppStateReader: | ||
M docs/config/apps.md +14 -0
| @@ -163,6 +163,20 @@ Each app gets a private network automatically. `[[network.shared]]` adds service | |||
| 163 | 163 | ||
| 164 | 164 | Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md). | |
| 165 | 165 | ||
| 166 | + | ## Telemetry | |
| 167 | + | ||
| 168 | + | When [telemetry log export](./telemetry.md#logs) is enabled, the stdout and stderr of every service are exported by default under `service.name = "<app>.<service>"`. `logs = false` opts out; a service setting overrides the app setting: | |
| 169 | + | ||
| 170 | + | ```toml | |
| 171 | + | [telemetry] | |
| 172 | + | logs = false # no service of this app exports... | |
| 173 | + | ||
| 174 | + | [services.web.telemetry] | |
| 175 | + | logs = true # ...except web | |
| 176 | + | ``` | |
| 177 | + | ||
| 178 | + | Changing either setting is a service change: it applies on the next deploy. | |
| 179 | + | ||
| 166 | 180 | ## Strictness | |
| 167 | 181 | ||
| 168 | 182 | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | |
| @@ -163,6 +163,20 @@ Each app gets a private network automatically. `[[network.shared]]` adds service | |||||
| 163 | 163 | ||||
| 164 | Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md). | 164 | Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md). | ||
| 165 | 165 | ||||
| 166 | + | ## Telemetry | |||
| 167 | + | ||||
| 168 | + | When [telemetry log export](./telemetry.md#logs) is enabled, the stdout and stderr of every service are exported by default under `service.name = "<app>.<service>"`. `logs = false` opts out; a service setting overrides the app setting: | |||
| 169 | + | ||||
| 170 | + | ```toml | |||
| 171 | + | [telemetry] | |||
| 172 | + | logs = false # no service of this app exports... | |||
| 173 | + | ||||
| 174 | + | [services.web.telemetry] | |||
| 175 | + | logs = true # ...except web | |||
| 176 | + | ``` | |||
| 177 | + | ||||
| 178 | + | Changing either setting is a service change: it applies on the next deploy. | |||
| 179 | + | ||||
| 166 | ## Strictness | 180 | ## Strictness | ||
| 167 | 181 | ||||
| 168 | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | 182 | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | ||
M docs/config/index.md +1 -1
| @@ -86,7 +86,7 @@ endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint | |||
| 86 | 86 | auth_token = "" # Base64 user:password for Basic auth | |
| 87 | 87 | traces = true # Export traces | |
| 88 | 88 | metrics = true # Export metrics | |
| 89 | - | logs = true # Bridge zerolog to OTLP logs | |
| 89 | + | logs = true # Export Gordon, access, and app logs | |
| 90 | 90 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | |
| 91 | 91 | ||
| 92 | 92 | # Installation secret store location | |
| @@ -86,7 +86,7 @@ endpoint = "http://localhost:5080/api/default" # OTLP HTTP endpoint | |||||
| 86 | auth_token = "" # Base64 user:password for Basic auth | 86 | auth_token = "" # Base64 user:password for Basic auth | ||
| 87 | traces = true # Export traces | 87 | traces = true # Export traces | ||
| 88 | metrics = true # Export metrics | 88 | metrics = true # Export metrics | ||
| 89 | - | logs = true # Bridge zerolog to OTLP logs | 89 | + | logs = true # Export Gordon, access, and app logs |
| 90 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | 90 | trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all | ||
| 91 | 91 | ||||
| 92 | # Installation secret store location | 92 | # Installation secret store location | ||
M docs/config/reference.md +2 -2
| @@ -107,7 +107,7 @@ endpoint = "" # OTLP HTTP endpoint URL | |||
| 107 | 107 | auth_token = "" # Base64-encoded user:password for Basic auth | |
| 108 | 108 | traces = true # Export distributed traces | |
| 109 | 109 | metrics = true # Export metrics | |
| 110 | - | logs = true # Bridge zerolog output to OTLP logs | |
| 110 | + | logs = true # Export Gordon, access, and app logs | |
| 111 | 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | |
| 112 | 112 | ||
| 113 | 113 | # ============================================================================= | |
| @@ -288,7 +288,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 288 | 288 | | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth | | |
| 289 | 289 | | `telemetry.traces` | `true` | Export distributed traces | | |
| 290 | 290 | | `telemetry.metrics` | `true` | Export metrics | | |
| 291 | - | | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs | | |
| 291 | + | | `telemetry.logs` | `true` | Export Gordon, proxy access, and app container logs to OTLP | | |
| 292 | 292 | | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) | | |
| 293 | 293 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | |
| 294 | 294 | | `network_isolation.enabled` | `true` | Network isolation enabled | | |
| @@ -107,7 +107,7 @@ endpoint = "" # OTLP HTTP endpoint URL | |||||
| 107 | auth_token = "" # Base64-encoded user:password for Basic auth | 107 | auth_token = "" # Base64-encoded user:password for Basic auth | ||
| 108 | traces = true # Export distributed traces | 108 | traces = true # Export distributed traces | ||
| 109 | metrics = true # Export metrics | 109 | metrics = true # Export metrics | ||
| 110 | - | logs = true # Bridge zerolog output to OTLP logs | 110 | + | logs = true # Export Gordon, access, and app logs |
| 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | 111 | trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0) | ||
| 112 | 112 | ||||
| 113 | # ============================================================================= | 113 | # ============================================================================= | ||
| @@ -288,7 +288,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 288 | | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth | | 288 | | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth | | ||
| 289 | | `telemetry.traces` | `true` | Export distributed traces | | 289 | | `telemetry.traces` | `true` | Export distributed traces | | ||
| 290 | | `telemetry.metrics` | `true` | Export metrics | | 290 | | `telemetry.metrics` | `true` | Export metrics | | ||
| 291 | - | | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs | | 291 | + | | `telemetry.logs` | `true` | Export Gordon, proxy access, and app container logs to OTLP | |
| 292 | | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) | | 292 | | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) | | ||
| 293 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | 293 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | ||
| 294 | | `network_isolation.enabled` | `true` | Network isolation enabled | | 294 | | `network_isolation.enabled` | `true` | Network isolation enabled | | ||
M docs/config/telemetry.md +51 -2
| @@ -26,7 +26,7 @@ trace_sample_rate = 1.0 | |||
| 26 | 26 | | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth | | |
| 27 | 27 | | `traces` | bool | `true` | Export distributed traces | | |
| 28 | 28 | | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) | | |
| 29 | - | | `logs` | bool | `true` | Bridge zerolog output to OTLP logs | | |
| 29 | + | | `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP | | |
| 30 | 30 | | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) | | |
| 31 | 31 | ||
| 32 | 32 | ## How It Works | |
| @@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled: | |||
| 35 | 35 | ||
| 36 | 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 | 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. | |
| 38 | + | 3. **Logs** -- Gordon exports three log families (see [Logs](#logs)). | |
| 39 | 39 | ||
| 40 | 40 | When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead. | |
| 41 | 41 | ||
| 42 | + | ## Logs | |
| 43 | + | ||
| 44 | + | With `logs = true`, Gordon exports: | |
| 45 | + | ||
| 46 | + | | Family | Source | `service.name` | | |
| 47 | + | |--------|--------|----------------| | |
| 48 | + | | Gordon process logs | zerowrap output, with `trace_id`/`span_id` inside traced requests | `gordon` | | |
| 49 | + | | Proxy access logs | Every proxied request, attributed to the app service owning the host | `<app>.<service>` (`gordon` for registry and unknown hosts) | | |
| 50 | + | | App container logs | stdout and stderr of every running app service container | `<app>.<service>` | | |
| 51 | + | ||
| 52 | + | Every app record carries these resource attributes: | |
| 53 | + | ||
| 54 | + | | Attribute | Example | Use | | |
| 55 | + | |-----------|---------|-----| | |
| 56 | + | | `service.name` | `blog.web` | One exact service; unique across apps | | |
| 57 | + | | `service.namespace` | `blog` | All services of one app | | |
| 58 | + | | `gordon.app` | `blog` | Filter by app | | |
| 59 | + | | `gordon.service` | `web` | Filter by service name across apps | | |
| 60 | + | ||
| 61 | + | Record attributes: | |
| 62 | + | ||
| 63 | + | - `log.type`: `access` or `container` | |
| 64 | + | - `log.iostream`: `stdout` or `stderr` (container logs) | |
| 65 | + | - Access logs: `http.request.method`, `http.response.status_code`, `server.address`, `url.path`, `client.address`, `request.id`, and more. Severity follows the status: `5xx` is `ERROR`, `4xx` is `WARN`, otherwise `INFO`. | |
| 66 | + | ||
| 67 | + | Loki indexes `service.name` and `service.namespace` as stream labels by default; the other attributes are structured metadata, filtered after the stream selector. Example queries in Grafana: | |
| 68 | + | ||
| 69 | + | ```text | |
| 70 | + | {service_namespace="blog"} # every log of the blog app | |
| 71 | + | {service_name="blog.web"} | log_type="access" # access logs of one service | |
| 72 | + | {service_namespace=~".+"} | gordon_service="web" # every "web" service, all apps | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | Container output is exported from the moment Gordon starts, including the first lines of every container deployed afterwards. Output emitted while Gordon is stopped is not exported. Lines longer than 64 KiB are truncated. Export is buffered and never slows the proxy or the app: on overload, the oldest records are dropped. | |
| 76 | + | ||
| 77 | + | ### Per-app opt-out | |
| 78 | + | ||
| 79 | + | App container logs are exported by default. Opt out in the [app manifest](./apps.md#telemetry), per app or per service; a service setting overrides the app setting: | |
| 80 | + | ||
| 81 | + | ```toml | |
| 82 | + | [telemetry] | |
| 83 | + | logs = true # app default | |
| 84 | + | ||
| 85 | + | [services.db.telemetry] | |
| 86 | + | logs = false # only "db" stops exporting | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | The opt-out covers container output only; access logs of the app's hosts are still exported. | |
| 90 | + | ||
| 42 | 91 | ## Endpoint URL | |
| 43 | 92 | ||
| 44 | 93 | The `endpoint` field accepts a full URL. Gordon parses it to extract: | |
| @@ -26,7 +26,7 @@ trace_sample_rate = 1.0 | |||||
| 26 | | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth | | 26 | | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth | | ||
| 27 | | `traces` | bool | `true` | Export distributed traces | | 27 | | `traces` | bool | `true` | Export distributed traces | | ||
| 28 | | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) | | 28 | | `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) | | ||
| 29 | - | | `logs` | bool | `true` | Bridge zerolog output to OTLP logs | | 29 | + | | `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP | |
| 30 | | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) | | 30 | | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) | | ||
| 31 | 31 | ||||
| 32 | ## How It Works | 32 | ## How It Works | ||
| @@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled: | |||||
| 35 | 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. | 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. | 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. | 38 | + | 3. **Logs** -- Gordon exports three log families (see [Logs](#logs)). |
| 39 | 39 | ||||
| 40 | When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead. | 40 | When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead. | ||
| 41 | 41 | ||||
| 42 | + | ## Logs | |||
| 43 | + | ||||
| 44 | + | With `logs = true`, Gordon exports: | |||
| 45 | + | ||||
| 46 | + | | Family | Source | `service.name` | | |||
| 47 | + | |--------|--------|----------------| | |||
| 48 | + | | Gordon process logs | zerowrap output, with `trace_id`/`span_id` inside traced requests | `gordon` | | |||
| 49 | + | | Proxy access logs | Every proxied request, attributed to the app service owning the host | `<app>.<service>` (`gordon` for registry and unknown hosts) | | |||
| 50 | + | | App container logs | stdout and stderr of every running app service container | `<app>.<service>` | | |||
| 51 | + | ||||
| 52 | + | Every app record carries these resource attributes: | |||
| 53 | + | ||||
| 54 | + | | Attribute | Example | Use | | |||
| 55 | + | |-----------|---------|-----| | |||
| 56 | + | | `service.name` | `blog.web` | One exact service; unique across apps | | |||
| 57 | + | | `service.namespace` | `blog` | All services of one app | | |||
| 58 | + | | `gordon.app` | `blog` | Filter by app | | |||
| 59 | + | | `gordon.service` | `web` | Filter by service name across apps | | |||
| 60 | + | ||||
| 61 | + | Record attributes: | |||
| 62 | + | ||||
| 63 | + | - `log.type`: `access` or `container` | |||
| 64 | + | - `log.iostream`: `stdout` or `stderr` (container logs) | |||
| 65 | + | - Access logs: `http.request.method`, `http.response.status_code`, `server.address`, `url.path`, `client.address`, `request.id`, and more. Severity follows the status: `5xx` is `ERROR`, `4xx` is `WARN`, otherwise `INFO`. | |||
| 66 | + | ||||
| 67 | + | Loki indexes `service.name` and `service.namespace` as stream labels by default; the other attributes are structured metadata, filtered after the stream selector. Example queries in Grafana: | |||
| 68 | + | ||||
| 69 | + | ```text | |||
| 70 | + | {service_namespace="blog"} # every log of the blog app | |||
| 71 | + | {service_name="blog.web"} | log_type="access" # access logs of one service | |||
| 72 | + | {service_namespace=~".+"} | gordon_service="web" # every "web" service, all apps | |||
| 73 | + | ``` | |||
| 74 | + | ||||
| 75 | + | Container output is exported from the moment Gordon starts, including the first lines of every container deployed afterwards. Output emitted while Gordon is stopped is not exported. Lines longer than 64 KiB are truncated. Export is buffered and never slows the proxy or the app: on overload, the oldest records are dropped. | |||
| 76 | + | ||||
| 77 | + | ### Per-app opt-out | |||
| 78 | + | ||||
| 79 | + | App container logs are exported by default. Opt out in the [app manifest](./apps.md#telemetry), per app or per service; a service setting overrides the app setting: | |||
| 80 | + | ||||
| 81 | + | ```toml | |||
| 82 | + | [telemetry] | |||
| 83 | + | logs = true # app default | |||
| 84 | + | ||||
| 85 | + | [services.db.telemetry] | |||
| 86 | + | logs = false # only "db" stops exporting | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | The opt-out covers container output only; access logs of the app's hosts are still exported. | |||
| 90 | + | ||||
| 42 | ## Endpoint URL | 91 | ## Endpoint URL | ||
| 43 | 92 | ||||
| 44 | The `endpoint` field accepts a full URL. Gordon parses it to extract: | 93 | The `endpoint` field accepts a full URL. Gordon parses it to extract: | ||
M internal/adapters/in/appmanifest/parse.go +25 -4
| @@ -23,10 +23,18 @@ import ( | |||
| 23 | 23 | // rawManifest mirrors the frozen TOML schema for strict decoding. | |
| 24 | 24 | // Services is keyed by service name: [services.<name>]. | |
| 25 | 25 | type rawManifest struct { | |
| 26 | - | Name string `toml:"name"` | |
| 27 | - | Env map[string]string `toml:"env"` | |
| 28 | - | Services map[string]rawService `toml:"services"` | |
| 29 | - | Network rawNetwork `toml:"network"` | |
| 26 | + | Name string `toml:"name"` | |
| 27 | + | Env map[string]string `toml:"env"` | |
| 28 | + | Services map[string]rawService `toml:"services"` | |
| 29 | + | Network rawNetwork `toml:"network"` | |
| 30 | + | Telemetry rawTelemetry `toml:"telemetry"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // rawTelemetry mirrors [telemetry] and [services.<name>.telemetry]. | |
| 34 | + | // A nil Logs means "inherit": service inherits the app, the app | |
| 35 | + | // inherits the default (export on). | |
| 36 | + | type rawTelemetry struct { | |
| 37 | + | Logs *bool `toml:"logs"` | |
| 30 | 38 | } | |
| 31 | 39 | ||
| 32 | 40 | // rawNetwork mirrors [network] and its [[network.shared]] children. | |
| @@ -52,6 +60,7 @@ type rawService struct { | |||
| 52 | 60 | Devices []string `toml:"devices"` | |
| 53 | 61 | Databases []rawDatabase `toml:"database"` | |
| 54 | 62 | Backup rawBackup `toml:"backup"` | |
| 63 | + | Telemetry rawTelemetry `toml:"telemetry"` | |
| 55 | 64 | } | |
| 56 | 65 | ||
| 57 | 66 | // rawReadiness mirrors [services.<name>.readiness]. | |
| @@ -221,6 +230,7 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||
| 221 | 230 | if err != nil { | |
| 222 | 231 | return domain.AppSpec{}, err | |
| 223 | 232 | } | |
| 233 | + | svc.LogExportDisabled = !resolveLogExport(raw.Telemetry.Logs, raw.Services[name].Telemetry.Logs) | |
| 224 | 234 | spec.Services = append(spec.Services, svc) | |
| 225 | 235 | } | |
| 226 | 236 | for _, net := range raw.Network.Shared { | |
| @@ -233,6 +243,17 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||
| 233 | 243 | return spec, nil | |
| 234 | 244 | } | |
| 235 | 245 | ||
| 246 | + | // resolveLogExport applies the precedence service > app > default (on). | |
| 247 | + | func resolveLogExport(app, service *bool) bool { | |
| 248 | + | if service != nil { | |
| 249 | + | return *service | |
| 250 | + | } | |
| 251 | + | if app != nil { | |
| 252 | + | return *app | |
| 253 | + | } | |
| 254 | + | return true | |
| 255 | + | } | |
| 256 | + | ||
| 236 | 257 | func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) { | |
| 237 | 258 | visibility := h.Visibility | |
| 238 | 259 | if visibility == "" { | |
| @@ -23,10 +23,18 @@ import ( | |||||
| 23 | // rawManifest mirrors the frozen TOML schema for strict decoding. | 23 | // rawManifest mirrors the frozen TOML schema for strict decoding. | ||
| 24 | // Services is keyed by service name: [services.<name>]. | 24 | // Services is keyed by service name: [services.<name>]. | ||
| 25 | type rawManifest struct { | 25 | type rawManifest struct { | ||
| 26 | - | Name string `toml:"name"` | 26 | + | Name string `toml:"name"` |
| 27 | - | Env map[string]string `toml:"env"` | 27 | + | Env map[string]string `toml:"env"` |
| 28 | - | Services map[string]rawService `toml:"services"` | 28 | + | Services map[string]rawService `toml:"services"` |
| 29 | - | Network rawNetwork `toml:"network"` | 29 | + | Network rawNetwork `toml:"network"` |
| 30 | + | Telemetry rawTelemetry `toml:"telemetry"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // rawTelemetry mirrors [telemetry] and [services.<name>.telemetry]. | |||
| 34 | + | // A nil Logs means "inherit": service inherits the app, the app | |||
| 35 | + | // inherits the default (export on). | |||
| 36 | + | type rawTelemetry struct { | |||
| 37 | + | Logs *bool `toml:"logs"` | |||
| 30 | } | 38 | } | ||
| 31 | 39 | ||||
| 32 | // rawNetwork mirrors [network] and its [[network.shared]] children. | 40 | // rawNetwork mirrors [network] and its [[network.shared]] children. | ||
| @@ -52,6 +60,7 @@ type rawService struct { | |||||
| 52 | Devices []string `toml:"devices"` | 60 | Devices []string `toml:"devices"` | ||
| 53 | Databases []rawDatabase `toml:"database"` | 61 | Databases []rawDatabase `toml:"database"` | ||
| 54 | Backup rawBackup `toml:"backup"` | 62 | Backup rawBackup `toml:"backup"` | ||
| 63 | + | Telemetry rawTelemetry `toml:"telemetry"` | |||
| 55 | } | 64 | } | ||
| 56 | 65 | ||||
| 57 | // rawReadiness mirrors [services.<name>.readiness]. | 66 | // rawReadiness mirrors [services.<name>.readiness]. | ||
| @@ -221,6 +230,7 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||||
| 221 | if err != nil { | 230 | if err != nil { | ||
| 222 | return domain.AppSpec{}, err | 231 | return domain.AppSpec{}, err | ||
| 223 | } | 232 | } | ||
| 233 | + | svc.LogExportDisabled = !resolveLogExport(raw.Telemetry.Logs, raw.Services[name].Telemetry.Logs) | |||
| 224 | spec.Services = append(spec.Services, svc) | 234 | spec.Services = append(spec.Services, svc) | ||
| 225 | } | 235 | } | ||
| 226 | for _, net := range raw.Network.Shared { | 236 | for _, net := range raw.Network.Shared { | ||
| @@ -233,6 +243,17 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) { | |||||
| 233 | return spec, nil | 243 | return spec, nil | ||
| 234 | } | 244 | } | ||
| 235 | 245 | ||||
| 246 | + | // resolveLogExport applies the precedence service > app > default (on). | |||
| 247 | + | func resolveLogExport(app, service *bool) bool { | |||
| 248 | + | if service != nil { | |||
| 249 | + | return *service | |||
| 250 | + | } | |||
| 251 | + | if app != nil { | |||
| 252 | + | return *app | |||
| 253 | + | } | |||
| 254 | + | return true | |||
| 255 | + | } | |||
| 256 | + | ||||
| 236 | func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) { | 257 | func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) { | ||
| 237 | visibility := h.Visibility | 258 | visibility := h.Visibility | ||
| 238 | if visibility == "" { | 259 | if visibility == "" { | ||
M internal/adapters/in/appmanifest/parse_test.go +32 -0
| @@ -571,3 +571,35 @@ gpus = "all" | |||
| 571 | 571 | _, _, err := appmanifest.Parse([]byte(doc), "demo.toml") | |
| 572 | 572 | require.Error(t, err) | |
| 573 | 573 | } | |
| 574 | + | ||
| 575 | + | func TestParse_TelemetryLogsPrecedence(t *testing.T) { | |
| 576 | + | const manifest = ` | |
| 577 | + | name = "blog" | |
| 578 | + | ||
| 579 | + | [telemetry] | |
| 580 | + | logs = false | |
| 581 | + | ||
| 582 | + | [services.web] | |
| 583 | + | image = "registry.example.com/blog/web:1" | |
| 584 | + | ||
| 585 | + | [services.web.telemetry] | |
| 586 | + | logs = true | |
| 587 | + | ||
| 588 | + | [services.db] | |
| 589 | + | image = "postgres:17" | |
| 590 | + | ` | |
| 591 | + | spec, _, err := appmanifest.Parse([]byte(manifest), "blog.toml") | |
| 592 | + | require.NoError(t, err) | |
| 593 | + | byName := map[string]domain.AppService{} | |
| 594 | + | for _, svc := range spec.Services { | |
| 595 | + | byName[svc.Name] = svc | |
| 596 | + | } | |
| 597 | + | assert.False(t, byName["web"].LogExportDisabled, "service override wins") | |
| 598 | + | assert.True(t, byName["db"].LogExportDisabled, "service inherits app") | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func TestParse_TelemetryLogsDefaultOn(t *testing.T) { | |
| 602 | + | spec, _, err := appmanifest.Parse([]byte(validWeb), "blog.toml") | |
| 603 | + | require.NoError(t, err) | |
| 604 | + | assert.False(t, spec.Services[0].LogExportDisabled) | |
| 605 | + | } | |
| @@ -571,3 +571,35 @@ gpus = "all" | |||||
| 571 | _, _, err := appmanifest.Parse([]byte(doc), "demo.toml") | 571 | _, _, err := appmanifest.Parse([]byte(doc), "demo.toml") | ||
| 572 | require.Error(t, err) | 572 | require.Error(t, err) | ||
| 573 | } | 573 | } | ||
| 574 | + | ||||
| 575 | + | func TestParse_TelemetryLogsPrecedence(t *testing.T) { | |||
| 576 | + | const manifest = ` | |||
| 577 | + | name = "blog" | |||
| 578 | + | ||||
| 579 | + | [telemetry] | |||
| 580 | + | logs = false | |||
| 581 | + | ||||
| 582 | + | [services.web] | |||
| 583 | + | image = "registry.example.com/blog/web:1" | |||
| 584 | + | ||||
| 585 | + | [services.web.telemetry] | |||
| 586 | + | logs = true | |||
| 587 | + | ||||
| 588 | + | [services.db] | |||
| 589 | + | image = "postgres:17" | |||
| 590 | + | ` | |||
| 591 | + | spec, _, err := appmanifest.Parse([]byte(manifest), "blog.toml") | |||
| 592 | + | require.NoError(t, err) | |||
| 593 | + | byName := map[string]domain.AppService{} | |||
| 594 | + | for _, svc := range spec.Services { | |||
| 595 | + | byName[svc.Name] = svc | |||
| 596 | + | } | |||
| 597 | + | assert.False(t, byName["web"].LogExportDisabled, "service override wins") | |||
| 598 | + | assert.True(t, byName["db"].LogExportDisabled, "service inherits app") | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func TestParse_TelemetryLogsDefaultOn(t *testing.T) { | |||
| 602 | + | spec, _, err := appmanifest.Parse([]byte(validWeb), "blog.toml") | |||
| 603 | + | require.NoError(t, err) | |||
| 604 | + | assert.False(t, spec.Services[0].LogExportDisabled) | |||
| 605 | + | } | |||
A internal/adapters/out/docker/log_stream.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package docker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | cerrdefs "github.com/containerd/errdefs" | |
| 12 | + | "github.com/moby/moby/api/pkg/stdcopy" | |
| 13 | + | "github.com/moby/moby/client" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // maxLogLineBytes bounds one exported line; longer lines are truncated | |
| 20 | + | // so one runaway writer cannot exhaust memory. | |
| 21 | + | const maxLogLineBytes = 64 * 1024 | |
| 22 | + | ||
| 23 | + | var _ out.ContainerLogStreamer = (*Runtime)(nil) | |
| 24 | + | ||
| 25 | + | // StreamContainerLogs implements out.ContainerLogStreamer. It follows | |
| 26 | + | // stdout and stderr with runtime timestamps, demultiplexes them, and | |
| 27 | + | // emits one line at a time until the stream ends or ctx is canceled. | |
| 28 | + | func (r *Runtime) StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error { | |
| 29 | + | options := client.ContainerLogsOptions{ | |
| 30 | + | ShowStdout: true, | |
| 31 | + | ShowStderr: true, | |
| 32 | + | Follow: true, | |
| 33 | + | Timestamps: true, | |
| 34 | + | } | |
| 35 | + | if !since.IsZero() { | |
| 36 | + | options.Since = since.UTC().Format(time.RFC3339Nano) | |
| 37 | + | } | |
| 38 | + | logs, err := r.client.ContainerLogs(ctx, containerID, options) | |
| 39 | + | if err != nil { | |
| 40 | + | if cerrdefs.IsNotFound(err) { | |
| 41 | + | return fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID) | |
| 42 | + | } | |
| 43 | + | return fmt.Errorf("follow container logs: %w", err) | |
| 44 | + | } | |
| 45 | + | defer logs.Close() | |
| 46 | + | // Closing the stream unblocks the demux copy on cancellation. | |
| 47 | + | stop := context.AfterFunc(ctx, func() { _ = logs.Close() }) | |
| 48 | + | defer stop() | |
| 49 | + | ||
| 50 | + | stdoutR, stdoutW := io.Pipe() | |
| 51 | + | stderrR, stderrW := io.Pipe() | |
| 52 | + | done := make(chan struct{}, 2) | |
| 53 | + | lines := make(chan domain.ContainerLogLine) | |
| 54 | + | go scanLogLines(stdoutR, domain.LogStreamStdout, lines, done) | |
| 55 | + | go scanLogLines(stderrR, domain.LogStreamStderr, lines, done) | |
| 56 | + | ||
| 57 | + | copyErr := make(chan error, 1) | |
| 58 | + | go func() { | |
| 59 | + | _, err := stdcopy.StdCopy(stdoutW, stderrW, logs) | |
| 60 | + | _ = stdoutW.Close() | |
| 61 | + | _ = stderrW.Close() | |
| 62 | + | copyErr <- err | |
| 63 | + | }() | |
| 64 | + | ||
| 65 | + | for pending := 2; pending > 0; { | |
| 66 | + | select { | |
| 67 | + | case line := <-lines: | |
| 68 | + | emit(line) | |
| 69 | + | case <-done: | |
| 70 | + | pending-- | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | err = <-copyErr | |
| 74 | + | if ctx.Err() != nil { | |
| 75 | + | return ctx.Err() | |
| 76 | + | } | |
| 77 | + | if err != nil { | |
| 78 | + | return fmt.Errorf("demultiplex container logs: %w", err) | |
| 79 | + | } | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // scanLogLines splits one demultiplexed stream into timestamped lines. | |
| 84 | + | // The reader is always drained so the demux copy never blocks. | |
| 85 | + | func scanLogLines(r *io.PipeReader, stream string, lines chan<- domain.ContainerLogLine, done chan<- struct{}) { | |
| 86 | + | defer func() { done <- struct{}{} }() | |
| 87 | + | reader := bufio.NewReaderSize(r, 4096) | |
| 88 | + | for { | |
| 89 | + | raw, err := readBoundedLine(reader) | |
| 90 | + | if raw != "" { | |
| 91 | + | lines <- parseTimestampedLine(raw, stream) | |
| 92 | + | } | |
| 93 | + | if err != nil { | |
| 94 | + | _ = r.CloseWithError(err) | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // readBoundedLine returns one line without its newline, truncated to | |
| 101 | + | // maxLogLineBytes; the rest of an oversized line is discarded. | |
| 102 | + | func readBoundedLine(reader *bufio.Reader) (string, error) { | |
| 103 | + | var b strings.Builder | |
| 104 | + | for { | |
| 105 | + | chunk, err := reader.ReadSlice('\n') | |
| 106 | + | if room := maxLogLineBytes - b.Len(); room > 0 { | |
| 107 | + | if len(chunk) > room { | |
| 108 | + | chunk = chunk[:room] | |
| 109 | + | } | |
| 110 | + | b.Write(chunk) | |
| 111 | + | } | |
| 112 | + | if err == bufio.ErrBufferFull { | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | return strings.TrimRight(b.String(), "\r\n"), err | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // parseTimestampedLine splits the RFC3339Nano prefix Docker adds when | |
| 120 | + | // Timestamps is set. Lines without a valid prefix keep the receive time. | |
| 121 | + | func parseTimestampedLine(raw, stream string) domain.ContainerLogLine { | |
| 122 | + | raw = strings.ToValidUTF8(raw, "\uFFFD") | |
| 123 | + | line := domain.ContainerLogLine{Time: time.Now().UTC(), Stream: stream, Body: raw} | |
| 124 | + | prefix, body, ok := strings.Cut(raw, " ") | |
| 125 | + | if !ok { | |
| 126 | + | return line | |
| 127 | + | } | |
| 128 | + | if ts, err := time.Parse(time.RFC3339Nano, prefix); err == nil { | |
| 129 | + | line.Time = ts | |
| 130 | + | line.Body = body | |
| 131 | + | } | |
| 132 | + | return line | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | cerrdefs "github.com/containerd/errdefs" | |||
| 12 | + | "github.com/moby/moby/api/pkg/stdcopy" | |||
| 13 | + | "github.com/moby/moby/client" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // maxLogLineBytes bounds one exported line; longer lines are truncated | |||
| 20 | + | // so one runaway writer cannot exhaust memory. | |||
| 21 | + | const maxLogLineBytes = 64 * 1024 | |||
| 22 | + | ||||
| 23 | + | var _ out.ContainerLogStreamer = (*Runtime)(nil) | |||
| 24 | + | ||||
| 25 | + | // StreamContainerLogs implements out.ContainerLogStreamer. It follows | |||
| 26 | + | // stdout and stderr with runtime timestamps, demultiplexes them, and | |||
| 27 | + | // emits one line at a time until the stream ends or ctx is canceled. | |||
| 28 | + | func (r *Runtime) StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error { | |||
| 29 | + | options := client.ContainerLogsOptions{ | |||
| 30 | + | ShowStdout: true, | |||
| 31 | + | ShowStderr: true, | |||
| 32 | + | Follow: true, | |||
| 33 | + | Timestamps: true, | |||
| 34 | + | } | |||
| 35 | + | if !since.IsZero() { | |||
| 36 | + | options.Since = since.UTC().Format(time.RFC3339Nano) | |||
| 37 | + | } | |||
| 38 | + | logs, err := r.client.ContainerLogs(ctx, containerID, options) | |||
| 39 | + | if err != nil { | |||
| 40 | + | if cerrdefs.IsNotFound(err) { | |||
| 41 | + | return fmt.Errorf("%w: %s", domain.ErrContainerNotFound, containerID) | |||
| 42 | + | } | |||
| 43 | + | return fmt.Errorf("follow container logs: %w", err) | |||
| 44 | + | } | |||
| 45 | + | defer logs.Close() | |||
| 46 | + | // Closing the stream unblocks the demux copy on cancellation. | |||
| 47 | + | stop := context.AfterFunc(ctx, func() { _ = logs.Close() }) | |||
| 48 | + | defer stop() | |||
| 49 | + | ||||
| 50 | + | stdoutR, stdoutW := io.Pipe() | |||
| 51 | + | stderrR, stderrW := io.Pipe() | |||
| 52 | + | done := make(chan struct{}, 2) | |||
| 53 | + | lines := make(chan domain.ContainerLogLine) | |||
| 54 | + | go scanLogLines(stdoutR, domain.LogStreamStdout, lines, done) | |||
| 55 | + | go scanLogLines(stderrR, domain.LogStreamStderr, lines, done) | |||
| 56 | + | ||||
| 57 | + | copyErr := make(chan error, 1) | |||
| 58 | + | go func() { | |||
| 59 | + | _, err := stdcopy.StdCopy(stdoutW, stderrW, logs) | |||
| 60 | + | _ = stdoutW.Close() | |||
| 61 | + | _ = stderrW.Close() | |||
| 62 | + | copyErr <- err | |||
| 63 | + | }() | |||
| 64 | + | ||||
| 65 | + | for pending := 2; pending > 0; { | |||
| 66 | + | select { | |||
| 67 | + | case line := <-lines: | |||
| 68 | + | emit(line) | |||
| 69 | + | case <-done: | |||
| 70 | + | pending-- | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | err = <-copyErr | |||
| 74 | + | if ctx.Err() != nil { | |||
| 75 | + | return ctx.Err() | |||
| 76 | + | } | |||
| 77 | + | if err != nil { | |||
| 78 | + | return fmt.Errorf("demultiplex container logs: %w", err) | |||
| 79 | + | } | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // scanLogLines splits one demultiplexed stream into timestamped lines. | |||
| 84 | + | // The reader is always drained so the demux copy never blocks. | |||
| 85 | + | func scanLogLines(r *io.PipeReader, stream string, lines chan<- domain.ContainerLogLine, done chan<- struct{}) { | |||
| 86 | + | defer func() { done <- struct{}{} }() | |||
| 87 | + | reader := bufio.NewReaderSize(r, 4096) | |||
| 88 | + | for { | |||
| 89 | + | raw, err := readBoundedLine(reader) | |||
| 90 | + | if raw != "" { | |||
| 91 | + | lines <- parseTimestampedLine(raw, stream) | |||
| 92 | + | } | |||
| 93 | + | if err != nil { | |||
| 94 | + | _ = r.CloseWithError(err) | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // readBoundedLine returns one line without its newline, truncated to | |||
| 101 | + | // maxLogLineBytes; the rest of an oversized line is discarded. | |||
| 102 | + | func readBoundedLine(reader *bufio.Reader) (string, error) { | |||
| 103 | + | var b strings.Builder | |||
| 104 | + | for { | |||
| 105 | + | chunk, err := reader.ReadSlice('\n') | |||
| 106 | + | if room := maxLogLineBytes - b.Len(); room > 0 { | |||
| 107 | + | if len(chunk) > room { | |||
| 108 | + | chunk = chunk[:room] | |||
| 109 | + | } | |||
| 110 | + | b.Write(chunk) | |||
| 111 | + | } | |||
| 112 | + | if err == bufio.ErrBufferFull { | |||
| 113 | + | continue | |||
| 114 | + | } | |||
| 115 | + | return strings.TrimRight(b.String(), "\r\n"), err | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // parseTimestampedLine splits the RFC3339Nano prefix Docker adds when | |||
| 120 | + | // Timestamps is set. Lines without a valid prefix keep the receive time. | |||
| 121 | + | func parseTimestampedLine(raw, stream string) domain.ContainerLogLine { | |||
| 122 | + | raw = strings.ToValidUTF8(raw, "\uFFFD") | |||
| 123 | + | line := domain.ContainerLogLine{Time: time.Now().UTC(), Stream: stream, Body: raw} | |||
| 124 | + | prefix, body, ok := strings.Cut(raw, " ") | |||
| 125 | + | if !ok { | |||
| 126 | + | return line | |||
| 127 | + | } | |||
| 128 | + | if ts, err := time.Parse(time.RFC3339Nano, prefix); err == nil { | |||
| 129 | + | line.Time = ts | |||
| 130 | + | line.Body = body | |||
| 131 | + | } | |||
| 132 | + | return line | |||
| 133 | + | } | |||
A internal/adapters/out/docker/log_stream_test.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package docker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/moby/moby/api/pkg/stdcopy" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestRuntime_StreamContainerLogs_DemuxesTimestampedLines(t *testing.T) { | |
| 21 | + | var gotFollow string | |
| 22 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 23 | + | gotFollow = r.URL.Query().Get("follow") | |
| 24 | + | w.Header().Set("Content-Type", "application/vnd.docker.multiplexed-stream") | |
| 25 | + | _, _ = w.Write(muxFrame(stdcopy.Stdout, "2026-09-10T12:00:01.5Z hello\n")) | |
| 26 | + | _, _ = w.Write(muxFrame(stdcopy.Stderr, "2026-09-10T12:00:02Z boom\n")) | |
| 27 | + | })) | |
| 28 | + | defer server.Close() | |
| 29 | + | ||
| 30 | + | runtime := newRuntimeForHTTPServer(t, server) | |
| 31 | + | var lines []domain.ContainerLogLine | |
| 32 | + | err := runtime.StreamContainerLogs(context.Background(), "c1", time.Time{}, func(l domain.ContainerLogLine) { | |
| 33 | + | lines = append(lines, l) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | require.NoError(t, err) | |
| 37 | + | assert.Equal(t, "1", gotFollow) | |
| 38 | + | require.Len(t, lines, 2) | |
| 39 | + | byStream := map[string]domain.ContainerLogLine{} | |
| 40 | + | for _, l := range lines { | |
| 41 | + | byStream[l.Stream] = l | |
| 42 | + | } | |
| 43 | + | assert.Equal(t, "hello", byStream[domain.LogStreamStdout].Body) | |
| 44 | + | assert.Equal(t, time.Date(2026, 9, 10, 12, 0, 1, 500000000, time.UTC), byStream[domain.LogStreamStdout].Time) | |
| 45 | + | assert.Equal(t, "boom", byStream[domain.LogStreamStderr].Body) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // muxFrame encodes one Docker multiplexed-stream frame. | |
| 49 | + | func muxFrame(stream stdcopy.StdType, payload string) []byte { | |
| 50 | + | frame := make([]byte, 8, 8+len(payload)) | |
| 51 | + | frame[0] = byte(stream) | |
| 52 | + | binary.BigEndian.PutUint32(frame[4:], uint32(len(payload))) | |
| 53 | + | return append(frame, payload...) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestRuntime_StreamContainerLogs_NotFound(t *testing.T) { | |
| 57 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 58 | + | w.Header().Set("Content-Type", "application/json") | |
| 59 | + | w.WriteHeader(http.StatusNotFound) | |
| 60 | + | _, _ = w.Write([]byte(`{"message":"No such container: gone"}`)) | |
| 61 | + | })) | |
| 62 | + | defer server.Close() | |
| 63 | + | ||
| 64 | + | runtime := newRuntimeForHTTPServer(t, server) | |
| 65 | + | err := runtime.StreamContainerLogs(context.Background(), "gone", time.Time{}, func(domain.ContainerLogLine) {}) | |
| 66 | + | ||
| 67 | + | assert.ErrorIs(t, err, domain.ErrContainerNotFound) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestReadBoundedLine_TruncatesOversizedLines(t *testing.T) { | |
| 71 | + | long := strings.Repeat("x", maxLogLineBytes+100) | |
| 72 | + | reader := bufio.NewReaderSize(strings.NewReader(long+"\nnext\n"), 16) | |
| 73 | + | ||
| 74 | + | first, err := readBoundedLine(reader) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | second, err := readBoundedLine(reader) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | ||
| 79 | + | assert.Len(t, first, maxLogLineBytes) | |
| 80 | + | assert.Equal(t, "next", second) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestParseTimestampedLine_KeepsBodyWithoutTimestamp(t *testing.T) { | |
| 84 | + | line := parseTimestampedLine("plain text line", domain.LogStreamStdout) | |
| 85 | + | assert.Equal(t, "plain text line", line.Body) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestParseTimestampedLine_SanitizesInvalidUTF8(t *testing.T) { | |
| 89 | + | line := parseTimestampedLine("2026-09-10T12:00:01Z bad\xffbytes", domain.LogStreamStdout) | |
| 90 | + | assert.Equal(t, "bad\uFFFDbytes", line.Body) | |
| 91 | + | ||
| 92 | + | plain := parseTimestampedLine("plain\xff", domain.LogStreamStdout) | |
| 93 | + | assert.Equal(t, "plain\uFFFD", plain.Body) | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/moby/moby/api/pkg/stdcopy" | |||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestRuntime_StreamContainerLogs_DemuxesTimestampedLines(t *testing.T) { | |||
| 21 | + | var gotFollow string | |||
| 22 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 23 | + | gotFollow = r.URL.Query().Get("follow") | |||
| 24 | + | w.Header().Set("Content-Type", "application/vnd.docker.multiplexed-stream") | |||
| 25 | + | _, _ = w.Write(muxFrame(stdcopy.Stdout, "2026-09-10T12:00:01.5Z hello\n")) | |||
| 26 | + | _, _ = w.Write(muxFrame(stdcopy.Stderr, "2026-09-10T12:00:02Z boom\n")) | |||
| 27 | + | })) | |||
| 28 | + | defer server.Close() | |||
| 29 | + | ||||
| 30 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 31 | + | var lines []domain.ContainerLogLine | |||
| 32 | + | err := runtime.StreamContainerLogs(context.Background(), "c1", time.Time{}, func(l domain.ContainerLogLine) { | |||
| 33 | + | lines = append(lines, l) | |||
| 34 | + | }) | |||
| 35 | + | ||||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | assert.Equal(t, "1", gotFollow) | |||
| 38 | + | require.Len(t, lines, 2) | |||
| 39 | + | byStream := map[string]domain.ContainerLogLine{} | |||
| 40 | + | for _, l := range lines { | |||
| 41 | + | byStream[l.Stream] = l | |||
| 42 | + | } | |||
| 43 | + | assert.Equal(t, "hello", byStream[domain.LogStreamStdout].Body) | |||
| 44 | + | assert.Equal(t, time.Date(2026, 9, 10, 12, 0, 1, 500000000, time.UTC), byStream[domain.LogStreamStdout].Time) | |||
| 45 | + | assert.Equal(t, "boom", byStream[domain.LogStreamStderr].Body) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // muxFrame encodes one Docker multiplexed-stream frame. | |||
| 49 | + | func muxFrame(stream stdcopy.StdType, payload string) []byte { | |||
| 50 | + | frame := make([]byte, 8, 8+len(payload)) | |||
| 51 | + | frame[0] = byte(stream) | |||
| 52 | + | binary.BigEndian.PutUint32(frame[4:], uint32(len(payload))) | |||
| 53 | + | return append(frame, payload...) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestRuntime_StreamContainerLogs_NotFound(t *testing.T) { | |||
| 57 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 58 | + | w.Header().Set("Content-Type", "application/json") | |||
| 59 | + | w.WriteHeader(http.StatusNotFound) | |||
| 60 | + | _, _ = w.Write([]byte(`{"message":"No such container: gone"}`)) | |||
| 61 | + | })) | |||
| 62 | + | defer server.Close() | |||
| 63 | + | ||||
| 64 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 65 | + | err := runtime.StreamContainerLogs(context.Background(), "gone", time.Time{}, func(domain.ContainerLogLine) {}) | |||
| 66 | + | ||||
| 67 | + | assert.ErrorIs(t, err, domain.ErrContainerNotFound) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestReadBoundedLine_TruncatesOversizedLines(t *testing.T) { | |||
| 71 | + | long := strings.Repeat("x", maxLogLineBytes+100) | |||
| 72 | + | reader := bufio.NewReaderSize(strings.NewReader(long+"\nnext\n"), 16) | |||
| 73 | + | ||||
| 74 | + | first, err := readBoundedLine(reader) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | second, err := readBoundedLine(reader) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | ||||
| 79 | + | assert.Len(t, first, maxLogLineBytes) | |||
| 80 | + | assert.Equal(t, "next", second) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestParseTimestampedLine_KeepsBodyWithoutTimestamp(t *testing.T) { | |||
| 84 | + | line := parseTimestampedLine("plain text line", domain.LogStreamStdout) | |||
| 85 | + | assert.Equal(t, "plain text line", line.Body) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestParseTimestampedLine_SanitizesInvalidUTF8(t *testing.T) { | |||
| 89 | + | line := parseTimestampedLine("2026-09-10T12:00:01Z bad\xffbytes", domain.LogStreamStdout) | |||
| 90 | + | assert.Equal(t, "bad\uFFFDbytes", line.Body) | |||
| 91 | + | ||||
| 92 | + | plain := parseTimestampedLine("plain\xff", domain.LogStreamStdout) | |||
| 93 | + | assert.Equal(t, "plain\uFFFD", plain.Body) | |||
| 94 | + | } | |||
M internal/adapters/out/eventbus/inmemory.go +4 -11
| @@ -9,10 +9,7 @@ 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" | |
| 14 | 12 | ||
| 15 | - | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 16 | 13 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | 14 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 15 | ) | |
| @@ -27,12 +24,12 @@ type InMemory struct { | |||
| 27 | 24 | cancel context.CancelFunc | |
| 28 | 25 | bufferSize int | |
| 29 | 26 | log zerowrap.Logger | |
| 30 | - | metrics *telemetry.Metrics | |
| 27 | + | metrics out.Metrics | |
| 31 | 28 | } | |
| 32 | 29 | ||
| 33 | 30 | // SetMetrics sets the telemetry metrics for the event bus. | |
| 34 | 31 | // Must be called before Start() to avoid data races on bus.metrics reads. | |
| 35 | - | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | |
| 32 | + | func (bus *InMemory) SetMetrics(m out.Metrics) { | |
| 36 | 33 | bus.mu.Lock() | |
| 37 | 34 | bus.metrics = m | |
| 38 | 35 | bus.mu.Unlock() | |
| @@ -98,9 +95,7 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||
| 98 | 95 | ||
| 99 | 96 | // Record dropped event metric | |
| 100 | 97 | if bus.metrics != nil { | |
| 101 | - | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | |
| 102 | - | attribute.String("event_type", string(event.Type)), | |
| 103 | - | )) | |
| 98 | + | bus.metrics.RecordEventDropped(context.Background(), event.Type) | |
| 104 | 99 | } | |
| 105 | 100 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | |
| 106 | 101 | } | |
| @@ -239,9 +234,7 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||
| 239 | 234 | ||
| 240 | 235 | // Record processed event metric | |
| 241 | 236 | if bus.metrics != nil { | |
| 242 | - | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | |
| 243 | - | attribute.String("event_type", string(event.Type)), | |
| 244 | - | )) | |
| 237 | + | bus.metrics.RecordEventProcessed(context.Background(), event.Type) | |
| 245 | 238 | } | |
| 246 | 239 | } | |
| 247 | 240 | case <-ctx.Done(): | |
| @@ -9,10 +9,7 @@ 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" | |||
| 14 | 12 | ||||
| 15 | - | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |||
| 16 | "github.com/bnema/gordon/internal/boundaries/out" | 13 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 14 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | ) | 15 | ) | ||
| @@ -27,12 +24,12 @@ type InMemory struct { | |||||
| 27 | cancel context.CancelFunc | 24 | cancel context.CancelFunc | ||
| 28 | bufferSize int | 25 | bufferSize int | ||
| 29 | log zerowrap.Logger | 26 | log zerowrap.Logger | ||
| 30 | - | metrics *telemetry.Metrics | 27 | + | metrics out.Metrics |
| 31 | } | 28 | } | ||
| 32 | 29 | ||||
| 33 | // SetMetrics sets the telemetry metrics for the event bus. | 30 | // SetMetrics sets the telemetry metrics for the event bus. | ||
| 34 | // Must be called before Start() to avoid data races on bus.metrics reads. | 31 | // Must be called before Start() to avoid data races on bus.metrics reads. | ||
| 35 | - | func (bus *InMemory) SetMetrics(m *telemetry.Metrics) { | 32 | + | func (bus *InMemory) SetMetrics(m out.Metrics) { |
| 36 | bus.mu.Lock() | 33 | bus.mu.Lock() | ||
| 37 | bus.metrics = m | 34 | bus.metrics = m | ||
| 38 | bus.mu.Unlock() | 35 | bus.mu.Unlock() | ||
| @@ -98,9 +95,7 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error { | |||||
| 98 | 95 | ||||
| 99 | // Record dropped event metric | 96 | // Record dropped event metric | ||
| 100 | if bus.metrics != nil { | 97 | if bus.metrics != nil { | ||
| 101 | - | bus.metrics.EventsDropped.Add(context.Background(), 1, metric.WithAttributes( | 98 | + | bus.metrics.RecordEventDropped(context.Background(), event.Type) |
| 102 | - | attribute.String("event_type", string(event.Type)), | |||
| 103 | - | )) | |||
| 104 | } | 99 | } | ||
| 105 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | 100 | return fmt.Errorf("event channel is full, dropping event %s", event.ID) | ||
| 106 | } | 101 | } | ||
| @@ -239,9 +234,7 @@ func (bus *InMemory) handleEvent(event domain.Event) { | |||||
| 239 | 234 | ||||
| 240 | // Record processed event metric | 235 | // Record processed event metric | ||
| 241 | if bus.metrics != nil { | 236 | if bus.metrics != nil { | ||
| 242 | - | bus.metrics.EventsProcessed.Add(context.Background(), 1, metric.WithAttributes( | 237 | + | bus.metrics.RecordEventProcessed(context.Background(), event.Type) |
| 243 | - | attribute.String("event_type", string(event.Type)), | |||
| 244 | - | )) | |||
| 245 | } | 238 | } | ||
| 246 | } | 239 | } | ||
| 247 | case <-ctx.Done(): | 240 | case <-ctx.Done(): | ||
A internal/adapters/out/telemetry/logs.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package telemetry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "go.opentelemetry.io/otel/attribute" | |
| 8 | + | otellog "go.opentelemetry.io/otel/log" | |
| 9 | + | sdklog "go.opentelemetry.io/otel/sdk/log" | |
| 10 | + | "go.opentelemetry.io/otel/sdk/resource" | |
| 11 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Attribute keys shared by every exported record. | |
| 18 | + | const ( | |
| 19 | + | attrGordonApp = "gordon.app" | |
| 20 | + | attrGordonService = "gordon.service" | |
| 21 | + | attrLogType = "log.type" | |
| 22 | + | attrLogStream = "log.iostream" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | var _ out.LogExporter = (*LogExporter)(nil) | |
| 26 | + | ||
| 27 | + | // LogExporter implements out.LogExporter over OTLP. | |
| 28 | + | // | |
| 29 | + | // OTLP carries service identity on the resource, not on the record, so | |
| 30 | + | // each source (Gordon or one app service) gets its own lazily created | |
| 31 | + | // LoggerProvider and resource. All providers share one exporter; the | |
| 32 | + | // batch processors of every provider serialize through it. | |
| 33 | + | type LogExporter struct { | |
| 34 | + | exporter sdklog.Exporter | |
| 35 | + | shared *serialExporter | |
| 36 | + | base *resource.Resource | |
| 37 | + | ||
| 38 | + | mu sync.Mutex | |
| 39 | + | providers map[domain.LogSource]*sdklog.LoggerProvider | |
| 40 | + | closed bool | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // NewLogExporter wraps exporter. base carries host/OS/version attributes | |
| 44 | + | // merged into every source resource; it may be nil. | |
| 45 | + | func NewLogExporter(exporter sdklog.Exporter, base *resource.Resource) *LogExporter { | |
| 46 | + | shared := &serialExporter{Exporter: exporter} | |
| 47 | + | return &LogExporter{ | |
| 48 | + | exporter: exporter, | |
| 49 | + | shared: shared, | |
| 50 | + | base: base, | |
| 51 | + | providers: map[domain.LogSource]*sdklog.LoggerProvider{}, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Export implements out.LogExporter. It never blocks on the network: | |
| 56 | + | // the batch processor queues records and drops the oldest on overload. | |
| 57 | + | func (e *LogExporter) Export(ctx context.Context, record domain.LogRecord) { | |
| 58 | + | provider := e.provider(record.Source) | |
| 59 | + | if provider == nil { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | var r otellog.Record | |
| 63 | + | r.SetTimestamp(record.Time) | |
| 64 | + | r.SetObservedTimestamp(record.Time) | |
| 65 | + | r.SetBody(attribute.StringValue(record.Body)) | |
| 66 | + | if sev, text := severity(record.Severity); sev != otellog.SeverityUndefined { | |
| 67 | + | r.SetSeverity(sev) | |
| 68 | + | r.SetSeverityText(text) | |
| 69 | + | } | |
| 70 | + | attrs := make([]attribute.KeyValue, 0, len(record.Attributes)+2) | |
| 71 | + | if record.Type != "" { | |
| 72 | + | attrs = append(attrs, attribute.String(attrLogType, record.Type)) | |
| 73 | + | } | |
| 74 | + | if record.Stream != "" { | |
| 75 | + | attrs = append(attrs, attribute.String(attrLogStream, record.Stream)) | |
| 76 | + | } | |
| 77 | + | for key, value := range record.Attributes { | |
| 78 | + | attrs = append(attrs, attribute.String(key, value)) | |
| 79 | + | } | |
| 80 | + | r.AddAttributes(attrs...) | |
| 81 | + | provider.Logger("gordon").Emit(ctx, r) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Shutdown flushes every source provider, then closes the exporter. | |
| 85 | + | func (e *LogExporter) Shutdown(ctx context.Context) error { | |
| 86 | + | e.mu.Lock() | |
| 87 | + | e.closed = true | |
| 88 | + | providers := e.providers | |
| 89 | + | e.providers = map[domain.LogSource]*sdklog.LoggerProvider{} | |
| 90 | + | e.mu.Unlock() | |
| 91 | + | ||
| 92 | + | var firstErr error | |
| 93 | + | for _, provider := range providers { | |
| 94 | + | if err := provider.Shutdown(ctx); err != nil && firstErr == nil { | |
| 95 | + | firstErr = err | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if err := e.exporter.Shutdown(ctx); err != nil && firstErr == nil { | |
| 99 | + | firstErr = err | |
| 100 | + | } | |
| 101 | + | return firstErr | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (e *LogExporter) provider(source domain.LogSource) *sdklog.LoggerProvider { | |
| 105 | + | e.mu.Lock() | |
| 106 | + | defer e.mu.Unlock() | |
| 107 | + | if e.closed { | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | if provider, ok := e.providers[source]; ok { | |
| 111 | + | return provider | |
| 112 | + | } | |
| 113 | + | // sourceResource is schemaless, so Merge cannot fail on schema | |
| 114 | + | // conflicts; its attributes win over base (service.name). | |
| 115 | + | res, err := resource.Merge(e.base, sourceResource(source)) | |
| 116 | + | if err != nil { | |
| 117 | + | res = sourceResource(source) | |
| 118 | + | } | |
| 119 | + | provider := sdklog.NewLoggerProvider( | |
| 120 | + | sdklog.WithProcessor(sdklog.NewBatchProcessor(e.shared)), | |
| 121 | + | sdklog.WithResource(res), | |
| 122 | + | ) | |
| 123 | + | e.providers[source] = provider | |
| 124 | + | return provider | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // sourceResource builds the resource identity of one source: | |
| 128 | + | // service.name "<app>.<service>" plus namespace and Gordon attributes. | |
| 129 | + | func sourceResource(source domain.LogSource) *resource.Resource { | |
| 130 | + | attrs := []attribute.KeyValue{semconv.ServiceName(source.ServiceName())} | |
| 131 | + | if !source.IsGordon() { | |
| 132 | + | attrs = append(attrs, | |
| 133 | + | semconv.ServiceNamespace(source.App), | |
| 134 | + | attribute.String(attrGordonApp, source.App), | |
| 135 | + | attribute.String(attrGordonService, source.Service), | |
| 136 | + | ) | |
| 137 | + | } | |
| 138 | + | return resource.NewSchemaless(attrs...) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func severity(level domain.LogSeverity) (otellog.Severity, string) { | |
| 142 | + | switch level { | |
| 143 | + | case domain.LogSeverityInfo: | |
| 144 | + | return otellog.SeverityInfo, "INFO" | |
| 145 | + | case domain.LogSeverityWarn: | |
| 146 | + | return otellog.SeverityWarn, "WARN" | |
| 147 | + | case domain.LogSeverityError: | |
| 148 | + | return otellog.SeverityError, "ERROR" | |
| 149 | + | default: | |
| 150 | + | return otellog.SeverityUndefined, "" | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // serialExporter serializes Export calls: the SDK forbids concurrent | |
| 155 | + | // Export on one exporter, and each source provider runs its own batcher. | |
| 156 | + | // Shutdown is ignored so one source provider shutting down cannot close | |
| 157 | + | // the exporter shared by the others; the owner shuts it down once. | |
| 158 | + | type serialExporter struct { | |
| 159 | + | sdklog.Exporter | |
| 160 | + | mu sync.Mutex | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (s *serialExporter) Export(ctx context.Context, records []sdklog.Record) error { | |
| 164 | + | s.mu.Lock() | |
| 165 | + | defer s.mu.Unlock() | |
| 166 | + | return s.Exporter.Export(ctx, records) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (s *serialExporter) Shutdown(context.Context) error { return nil } | |
| @@ -0,0 +1,169 @@ | |||||
| 1 | + | package telemetry | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "go.opentelemetry.io/otel/attribute" | |||
| 8 | + | otellog "go.opentelemetry.io/otel/log" | |||
| 9 | + | sdklog "go.opentelemetry.io/otel/sdk/log" | |||
| 10 | + | "go.opentelemetry.io/otel/sdk/resource" | |||
| 11 | + | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Attribute keys shared by every exported record. | |||
| 18 | + | const ( | |||
| 19 | + | attrGordonApp = "gordon.app" | |||
| 20 | + | attrGordonService = "gordon.service" | |||
| 21 | + | attrLogType = "log.type" | |||
| 22 | + | attrLogStream = "log.iostream" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | var _ out.LogExporter = (*LogExporter)(nil) | |||
| 26 | + | ||||
| 27 | + | // LogExporter implements out.LogExporter over OTLP. | |||
| 28 | + | // | |||
| 29 | + | // OTLP carries service identity on the resource, not on the record, so | |||
| 30 | + | // each source (Gordon or one app service) gets its own lazily created | |||
| 31 | + | // LoggerProvider and resource. All providers share one exporter; the | |||
| 32 | + | // batch processors of every provider serialize through it. | |||
| 33 | + | type LogExporter struct { | |||
| 34 | + | exporter sdklog.Exporter | |||
| 35 | + | shared *serialExporter | |||
| 36 | + | base *resource.Resource | |||
| 37 | + | ||||
| 38 | + | mu sync.Mutex | |||
| 39 | + | providers map[domain.LogSource]*sdklog.LoggerProvider | |||
| 40 | + | closed bool | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // NewLogExporter wraps exporter. base carries host/OS/version attributes | |||
| 44 | + | // merged into every source resource; it may be nil. | |||
| 45 | + | func NewLogExporter(exporter sdklog.Exporter, base *resource.Resource) *LogExporter { | |||
| 46 | + | shared := &serialExporter{Exporter: exporter} | |||
| 47 | + | return &LogExporter{ | |||
| 48 | + | exporter: exporter, | |||
| 49 | + | shared: shared, | |||
| 50 | + | base: base, | |||
| 51 | + | providers: map[domain.LogSource]*sdklog.LoggerProvider{}, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Export implements out.LogExporter. It never blocks on the network: | |||
| 56 | + | // the batch processor queues records and drops the oldest on overload. | |||
| 57 | + | func (e *LogExporter) Export(ctx context.Context, record domain.LogRecord) { | |||
| 58 | + | provider := e.provider(record.Source) | |||
| 59 | + | if provider == nil { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | var r otellog.Record | |||
| 63 | + | r.SetTimestamp(record.Time) | |||
| 64 | + | r.SetObservedTimestamp(record.Time) | |||
| 65 | + | r.SetBody(attribute.StringValue(record.Body)) | |||
| 66 | + | if sev, text := severity(record.Severity); sev != otellog.SeverityUndefined { | |||
| 67 | + | r.SetSeverity(sev) | |||
| 68 | + | r.SetSeverityText(text) | |||
| 69 | + | } | |||
| 70 | + | attrs := make([]attribute.KeyValue, 0, len(record.Attributes)+2) | |||
| 71 | + | if record.Type != "" { | |||
| 72 | + | attrs = append(attrs, attribute.String(attrLogType, record.Type)) | |||
| 73 | + | } | |||
| 74 | + | if record.Stream != "" { | |||
| 75 | + | attrs = append(attrs, attribute.String(attrLogStream, record.Stream)) | |||
| 76 | + | } | |||
| 77 | + | for key, value := range record.Attributes { | |||
| 78 | + | attrs = append(attrs, attribute.String(key, value)) | |||
| 79 | + | } | |||
| 80 | + | r.AddAttributes(attrs...) | |||
| 81 | + | provider.Logger("gordon").Emit(ctx, r) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Shutdown flushes every source provider, then closes the exporter. | |||
| 85 | + | func (e *LogExporter) Shutdown(ctx context.Context) error { | |||
| 86 | + | e.mu.Lock() | |||
| 87 | + | e.closed = true | |||
| 88 | + | providers := e.providers | |||
| 89 | + | e.providers = map[domain.LogSource]*sdklog.LoggerProvider{} | |||
| 90 | + | e.mu.Unlock() | |||
| 91 | + | ||||
| 92 | + | var firstErr error | |||
| 93 | + | for _, provider := range providers { | |||
| 94 | + | if err := provider.Shutdown(ctx); err != nil && firstErr == nil { | |||
| 95 | + | firstErr = err | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if err := e.exporter.Shutdown(ctx); err != nil && firstErr == nil { | |||
| 99 | + | firstErr = err | |||
| 100 | + | } | |||
| 101 | + | return firstErr | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (e *LogExporter) provider(source domain.LogSource) *sdklog.LoggerProvider { | |||
| 105 | + | e.mu.Lock() | |||
| 106 | + | defer e.mu.Unlock() | |||
| 107 | + | if e.closed { | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | if provider, ok := e.providers[source]; ok { | |||
| 111 | + | return provider | |||
| 112 | + | } | |||
| 113 | + | // sourceResource is schemaless, so Merge cannot fail on schema | |||
| 114 | + | // conflicts; its attributes win over base (service.name). | |||
| 115 | + | res, err := resource.Merge(e.base, sourceResource(source)) | |||
| 116 | + | if err != nil { | |||
| 117 | + | res = sourceResource(source) | |||
| 118 | + | } | |||
| 119 | + | provider := sdklog.NewLoggerProvider( | |||
| 120 | + | sdklog.WithProcessor(sdklog.NewBatchProcessor(e.shared)), | |||
| 121 | + | sdklog.WithResource(res), | |||
| 122 | + | ) | |||
| 123 | + | e.providers[source] = provider | |||
| 124 | + | return provider | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // sourceResource builds the resource identity of one source: | |||
| 128 | + | // service.name "<app>.<service>" plus namespace and Gordon attributes. | |||
| 129 | + | func sourceResource(source domain.LogSource) *resource.Resource { | |||
| 130 | + | attrs := []attribute.KeyValue{semconv.ServiceName(source.ServiceName())} | |||
| 131 | + | if !source.IsGordon() { | |||
| 132 | + | attrs = append(attrs, | |||
| 133 | + | semconv.ServiceNamespace(source.App), | |||
| 134 | + | attribute.String(attrGordonApp, source.App), | |||
| 135 | + | attribute.String(attrGordonService, source.Service), | |||
| 136 | + | ) | |||
| 137 | + | } | |||
| 138 | + | return resource.NewSchemaless(attrs...) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func severity(level domain.LogSeverity) (otellog.Severity, string) { | |||
| 142 | + | switch level { | |||
| 143 | + | case domain.LogSeverityInfo: | |||
| 144 | + | return otellog.SeverityInfo, "INFO" | |||
| 145 | + | case domain.LogSeverityWarn: | |||
| 146 | + | return otellog.SeverityWarn, "WARN" | |||
| 147 | + | case domain.LogSeverityError: | |||
| 148 | + | return otellog.SeverityError, "ERROR" | |||
| 149 | + | default: | |||
| 150 | + | return otellog.SeverityUndefined, "" | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // serialExporter serializes Export calls: the SDK forbids concurrent | |||
| 155 | + | // Export on one exporter, and each source provider runs its own batcher. | |||
| 156 | + | // Shutdown is ignored so one source provider shutting down cannot close | |||
| 157 | + | // the exporter shared by the others; the owner shuts it down once. | |||
| 158 | + | type serialExporter struct { | |||
| 159 | + | sdklog.Exporter | |||
| 160 | + | mu sync.Mutex | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (s *serialExporter) Export(ctx context.Context, records []sdklog.Record) error { | |||
| 164 | + | s.mu.Lock() | |||
| 165 | + | defer s.mu.Unlock() | |||
| 166 | + | return s.Exporter.Export(ctx, records) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (s *serialExporter) Shutdown(context.Context) error { return nil } | |||
A internal/adapters/out/telemetry/logs_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package telemetry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | "go.opentelemetry.io/otel/attribute" | |
| 12 | + | otellog "go.opentelemetry.io/otel/log" | |
| 13 | + | sdklog "go.opentelemetry.io/otel/sdk/log" | |
| 14 | + | "go.opentelemetry.io/otel/sdk/resource" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type recordingExporter struct { | |
| 20 | + | mu sync.Mutex | |
| 21 | + | records []sdklog.Record | |
| 22 | + | shutdown int | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (e *recordingExporter) Export(_ context.Context, records []sdklog.Record) error { | |
| 26 | + | e.mu.Lock() | |
| 27 | + | defer e.mu.Unlock() | |
| 28 | + | for _, r := range records { | |
| 29 | + | e.records = append(e.records, r.Clone()) | |
| 30 | + | } | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (e *recordingExporter) Shutdown(context.Context) error { | |
| 35 | + | e.mu.Lock() | |
| 36 | + | defer e.mu.Unlock() | |
| 37 | + | e.shutdown++ | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (e *recordingExporter) ForceFlush(context.Context) error { return nil } | |
| 42 | + | ||
| 43 | + | func resourceAttr(r sdklog.Record, key string) string { | |
| 44 | + | res := r.Resource() | |
| 45 | + | value, _ := res.Set().Value(attribute.Key(key)) | |
| 46 | + | return value.AsString() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func recordAttr(r sdklog.Record, key string) string { | |
| 50 | + | var found string | |
| 51 | + | r.WalkAttributes(func(kv attribute.KeyValue) bool { | |
| 52 | + | if string(kv.Key) == key { | |
| 53 | + | found = kv.Value.AsString() | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | return true | |
| 57 | + | }) | |
| 58 | + | return found | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestLogExporter_ExportsPerSourceIdentity(t *testing.T) { | |
| 62 | + | rec := &recordingExporter{} | |
| 63 | + | base := resource.NewSchemaless(attribute.String("service.name", "gordon"), attribute.String("host.name", "node-1")) | |
| 64 | + | exp := NewLogExporter(rec, base) | |
| 65 | + | ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 66 | + | ||
| 67 | + | exp.Export(context.Background(), domain.LogRecord{ | |
| 68 | + | Time: ts, | |
| 69 | + | Source: domain.LogSource{App: "blog", Service: "web"}, | |
| 70 | + | Type: domain.LogTypeContainer, | |
| 71 | + | Stream: domain.LogStreamStderr, | |
| 72 | + | Severity: domain.LogSeverityError, | |
| 73 | + | Body: "boom", | |
| 74 | + | }) | |
| 75 | + | exp.Export(context.Background(), domain.LogRecord{ | |
| 76 | + | Time: ts, | |
| 77 | + | Source: domain.LogSource{App: "shop", Service: "web"}, | |
| 78 | + | Body: "hello", | |
| 79 | + | }) | |
| 80 | + | require.NoError(t, exp.Shutdown(context.Background())) | |
| 81 | + | ||
| 82 | + | require.Len(t, rec.records, 2) | |
| 83 | + | byService := map[string]sdklog.Record{} | |
| 84 | + | for _, r := range rec.records { | |
| 85 | + | byService[resourceAttr(r, "service.name")] = r | |
| 86 | + | } | |
| 87 | + | blog, ok := byService["blog.web"] | |
| 88 | + | require.True(t, ok, "services with the same name stay distinct per app") | |
| 89 | + | _, ok = byService["shop.web"] | |
| 90 | + | require.True(t, ok) | |
| 91 | + | ||
| 92 | + | assert.Equal(t, "blog", resourceAttr(blog, "service.namespace")) | |
| 93 | + | assert.Equal(t, "blog", resourceAttr(blog, attrGordonApp)) | |
| 94 | + | assert.Equal(t, "web", resourceAttr(blog, attrGordonService)) | |
| 95 | + | assert.Equal(t, "node-1", resourceAttr(blog, "host.name"), "base attributes are kept") | |
| 96 | + | assert.Equal(t, "boom", blog.Body().AsString()) | |
| 97 | + | assert.Equal(t, ts, blog.Timestamp()) | |
| 98 | + | assert.Equal(t, otellog.SeverityError, blog.Severity()) | |
| 99 | + | assert.Equal(t, domain.LogTypeContainer, recordAttr(blog, attrLogType)) | |
| 100 | + | assert.Equal(t, domain.LogStreamStderr, recordAttr(blog, attrLogStream)) | |
| 101 | + | assert.Equal(t, 1, rec.shutdown, "shared exporter is shut down exactly once") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestLogExporter_DropsAfterShutdown(t *testing.T) { | |
| 105 | + | rec := &recordingExporter{} | |
| 106 | + | exp := NewLogExporter(rec, nil) | |
| 107 | + | require.NoError(t, exp.Shutdown(context.Background())) | |
| 108 | + | ||
| 109 | + | exp.Export(context.Background(), domain.LogRecord{Source: domain.LogSource{App: "blog", Service: "web"}, Body: "late"}) | |
| 110 | + | ||
| 111 | + | assert.Empty(t, rec.records) | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package telemetry | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | "go.opentelemetry.io/otel/attribute" | |||
| 12 | + | otellog "go.opentelemetry.io/otel/log" | |||
| 13 | + | sdklog "go.opentelemetry.io/otel/sdk/log" | |||
| 14 | + | "go.opentelemetry.io/otel/sdk/resource" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type recordingExporter struct { | |||
| 20 | + | mu sync.Mutex | |||
| 21 | + | records []sdklog.Record | |||
| 22 | + | shutdown int | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (e *recordingExporter) Export(_ context.Context, records []sdklog.Record) error { | |||
| 26 | + | e.mu.Lock() | |||
| 27 | + | defer e.mu.Unlock() | |||
| 28 | + | for _, r := range records { | |||
| 29 | + | e.records = append(e.records, r.Clone()) | |||
| 30 | + | } | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (e *recordingExporter) Shutdown(context.Context) error { | |||
| 35 | + | e.mu.Lock() | |||
| 36 | + | defer e.mu.Unlock() | |||
| 37 | + | e.shutdown++ | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (e *recordingExporter) ForceFlush(context.Context) error { return nil } | |||
| 42 | + | ||||
| 43 | + | func resourceAttr(r sdklog.Record, key string) string { | |||
| 44 | + | res := r.Resource() | |||
| 45 | + | value, _ := res.Set().Value(attribute.Key(key)) | |||
| 46 | + | return value.AsString() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func recordAttr(r sdklog.Record, key string) string { | |||
| 50 | + | var found string | |||
| 51 | + | r.WalkAttributes(func(kv attribute.KeyValue) bool { | |||
| 52 | + | if string(kv.Key) == key { | |||
| 53 | + | found = kv.Value.AsString() | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | return true | |||
| 57 | + | }) | |||
| 58 | + | return found | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestLogExporter_ExportsPerSourceIdentity(t *testing.T) { | |||
| 62 | + | rec := &recordingExporter{} | |||
| 63 | + | base := resource.NewSchemaless(attribute.String("service.name", "gordon"), attribute.String("host.name", "node-1")) | |||
| 64 | + | exp := NewLogExporter(rec, base) | |||
| 65 | + | ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 66 | + | ||||
| 67 | + | exp.Export(context.Background(), domain.LogRecord{ | |||
| 68 | + | Time: ts, | |||
| 69 | + | Source: domain.LogSource{App: "blog", Service: "web"}, | |||
| 70 | + | Type: domain.LogTypeContainer, | |||
| 71 | + | Stream: domain.LogStreamStderr, | |||
| 72 | + | Severity: domain.LogSeverityError, | |||
| 73 | + | Body: "boom", | |||
| 74 | + | }) | |||
| 75 | + | exp.Export(context.Background(), domain.LogRecord{ | |||
| 76 | + | Time: ts, | |||
| 77 | + | Source: domain.LogSource{App: "shop", Service: "web"}, | |||
| 78 | + | Body: "hello", | |||
| 79 | + | }) | |||
| 80 | + | require.NoError(t, exp.Shutdown(context.Background())) | |||
| 81 | + | ||||
| 82 | + | require.Len(t, rec.records, 2) | |||
| 83 | + | byService := map[string]sdklog.Record{} | |||
| 84 | + | for _, r := range rec.records { | |||
| 85 | + | byService[resourceAttr(r, "service.name")] = r | |||
| 86 | + | } | |||
| 87 | + | blog, ok := byService["blog.web"] | |||
| 88 | + | require.True(t, ok, "services with the same name stay distinct per app") | |||
| 89 | + | _, ok = byService["shop.web"] | |||
| 90 | + | require.True(t, ok) | |||
| 91 | + | ||||
| 92 | + | assert.Equal(t, "blog", resourceAttr(blog, "service.namespace")) | |||
| 93 | + | assert.Equal(t, "blog", resourceAttr(blog, attrGordonApp)) | |||
| 94 | + | assert.Equal(t, "web", resourceAttr(blog, attrGordonService)) | |||
| 95 | + | assert.Equal(t, "node-1", resourceAttr(blog, "host.name"), "base attributes are kept") | |||
| 96 | + | assert.Equal(t, "boom", blog.Body().AsString()) | |||
| 97 | + | assert.Equal(t, ts, blog.Timestamp()) | |||
| 98 | + | assert.Equal(t, otellog.SeverityError, blog.Severity()) | |||
| 99 | + | assert.Equal(t, domain.LogTypeContainer, recordAttr(blog, attrLogType)) | |||
| 100 | + | assert.Equal(t, domain.LogStreamStderr, recordAttr(blog, attrLogStream)) | |||
| 101 | + | assert.Equal(t, 1, rec.shutdown, "shared exporter is shut down exactly once") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestLogExporter_DropsAfterShutdown(t *testing.T) { | |||
| 105 | + | rec := &recordingExporter{} | |||
| 106 | + | exp := NewLogExporter(rec, nil) | |||
| 107 | + | require.NoError(t, exp.Shutdown(context.Background())) | |||
| 108 | + | ||||
| 109 | + | exp.Export(context.Background(), domain.LogRecord{Source: domain.LogSource{App: "blog", Service: "web"}, Body: "late"}) | |||
| 110 | + | ||||
| 111 | + | assert.Empty(t, rec.records) | |||
| 112 | + | } | |||
M internal/adapters/out/telemetry/metrics.go +28 -0
| @@ -1,10 +1,18 @@ | |||
| 1 | 1 | package telemetry | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 4 | 6 | "go.opentelemetry.io/otel" | |
| 7 | + | "go.opentelemetry.io/otel/attribute" | |
| 5 | 8 | "go.opentelemetry.io/otel/metric" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 6 | 12 | ) | |
| 7 | 13 | ||
| 14 | + | var _ out.Metrics = (*Metrics)(nil) | |
| 15 | + | ||
| 8 | 16 | // Metrics holds Gordon-specific OTel metrics instruments. | |
| 9 | 17 | type Metrics struct { | |
| 10 | 18 | // Deployments | |
| @@ -80,3 +88,23 @@ func NewMetrics() (*Metrics, error) { | |||
| 80 | 88 | ||
| 81 | 89 | return m, nil | |
| 82 | 90 | } | |
| 91 | + | ||
| 92 | + | // RecordImagePush implements out.Metrics. | |
| 93 | + | func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) { | |
| 94 | + | attrs := metric.WithAttributes( | |
| 95 | + | attribute.String("name", name), | |
| 96 | + | attribute.String("reference", reference), | |
| 97 | + | ) | |
| 98 | + | m.ImagePushTotal.Add(ctx, 1, attrs) | |
| 99 | + | m.ImagePushSize.Add(ctx, sizeBytes, attrs) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // RecordEventProcessed implements out.Metrics. | |
| 103 | + | func (m *Metrics) RecordEventProcessed(ctx context.Context, eventType domain.EventType) { | |
| 104 | + | m.EventsProcessed.Add(ctx, 1, metric.WithAttributes(attribute.String("event_type", string(eventType)))) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // RecordEventDropped implements out.Metrics. | |
| 108 | + | func (m *Metrics) RecordEventDropped(ctx context.Context, eventType domain.EventType) { | |
| 109 | + | m.EventsDropped.Add(ctx, 1, metric.WithAttributes(attribute.String("event_type", string(eventType)))) | |
| 110 | + | } | |
| @@ -1,10 +1,18 @@ | |||||
| 1 | package telemetry | 1 | package telemetry | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | ||||
| 4 | "go.opentelemetry.io/otel" | 6 | "go.opentelemetry.io/otel" | ||
| 7 | + | "go.opentelemetry.io/otel/attribute" | |||
| 5 | "go.opentelemetry.io/otel/metric" | 8 | "go.opentelemetry.io/otel/metric" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 6 | ) | 12 | ) | ||
| 7 | 13 | ||||
| 14 | + | var _ out.Metrics = (*Metrics)(nil) | |||
| 15 | + | ||||
| 8 | // Metrics holds Gordon-specific OTel metrics instruments. | 16 | // Metrics holds Gordon-specific OTel metrics instruments. | ||
| 9 | type Metrics struct { | 17 | type Metrics struct { | ||
| 10 | // Deployments | 18 | // Deployments | ||
| @@ -80,3 +88,23 @@ func NewMetrics() (*Metrics, error) { | |||||
| 80 | 88 | ||||
| 81 | return m, nil | 89 | return m, nil | ||
| 82 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | // RecordImagePush implements out.Metrics. | |||
| 93 | + | func (m *Metrics) RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) { | |||
| 94 | + | attrs := metric.WithAttributes( | |||
| 95 | + | attribute.String("name", name), | |||
| 96 | + | attribute.String("reference", reference), | |||
| 97 | + | ) | |||
| 98 | + | m.ImagePushTotal.Add(ctx, 1, attrs) | |||
| 99 | + | m.ImagePushSize.Add(ctx, sizeBytes, attrs) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // RecordEventProcessed implements out.Metrics. | |||
| 103 | + | func (m *Metrics) RecordEventProcessed(ctx context.Context, eventType domain.EventType) { | |||
| 104 | + | m.EventsProcessed.Add(ctx, 1, metric.WithAttributes(attribute.String("event_type", string(eventType)))) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // RecordEventDropped implements out.Metrics. | |||
| 108 | + | func (m *Metrics) RecordEventDropped(ctx context.Context, eventType domain.EventType) { | |||
| 109 | + | m.EventsDropped.Add(ctx, 1, metric.WithAttributes(attribute.String("event_type", string(eventType)))) | |||
| 110 | + | } | |||
M internal/adapters/out/telemetry/provider.go +19 -1
| @@ -26,7 +26,7 @@ type Config struct { | |||
| 26 | 26 | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | |
| 27 | 27 | Traces bool `mapstructure:"traces"` // Enable trace export | |
| 28 | 28 | Metrics bool `mapstructure:"metrics"` // Enable metric export | |
| 29 | - | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | |
| 29 | + | Logs bool `mapstructure:"logs"` // Export Gordon, proxy access, and app container logs to OTLP | |
| 30 | 30 | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample | |
| 31 | 31 | } | |
| 32 | 32 | ||
| @@ -35,6 +35,9 @@ type Provider struct { | |||
| 35 | 35 | TracerProvider *trace.TracerProvider | |
| 36 | 36 | MeterProvider *metric.MeterProvider | |
| 37 | 37 | LogProvider *otellog.LoggerProvider | |
| 38 | + | // WorkloadLogs exports proxy access and app container logs, each | |
| 39 | + | // under its own service identity. Nil when log export is disabled. | |
| 40 | + | WorkloadLogs *LogExporter | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | // endpointConfig holds parsed endpoint details shared across exporter setup. | |
| @@ -218,5 +221,20 @@ func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *re | |||
| 218 | 221 | ) | |
| 219 | 222 | p.LogProvider = lp | |
| 220 | 223 | *shutdowns = append(*shutdowns, lp.Shutdown) | |
| 224 | + | ||
| 225 | + | // Workload logs use a dedicated exporter: the SDK forbids concurrent | |
| 226 | + | // Export calls, and Gordon's own provider already owns logExp. | |
| 227 | + | workloadExp, err := otlploghttp.New(ctx, logOpts...) | |
| 228 | + | if err != nil { | |
| 229 | + | return fmt.Errorf("create workload log exporter: %w", err) | |
| 230 | + | } | |
| 231 | + | // Host and OS only: app records must not inherit Gordon's | |
| 232 | + | // service.name or service.version. | |
| 233 | + | base, err := resource.New(ctx, resource.WithOS(), resource.WithHost()) | |
| 234 | + | if err != nil { | |
| 235 | + | return fmt.Errorf("create workload log resource: %w", err) | |
| 236 | + | } | |
| 237 | + | p.WorkloadLogs = NewLogExporter(workloadExp, base) | |
| 238 | + | *shutdowns = append(*shutdowns, p.WorkloadLogs.Shutdown) | |
| 221 | 239 | return nil | |
| 222 | 240 | } | |
| @@ -26,7 +26,7 @@ type Config struct { | |||||
| 26 | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | 26 | AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass) | ||
| 27 | Traces bool `mapstructure:"traces"` // Enable trace export | 27 | Traces bool `mapstructure:"traces"` // Enable trace export | ||
| 28 | Metrics bool `mapstructure:"metrics"` // Enable metric export | 28 | Metrics bool `mapstructure:"metrics"` // Enable metric export | ||
| 29 | - | Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP | 29 | + | Logs bool `mapstructure:"logs"` // Export Gordon, proxy access, and app container logs to OTLP |
| 30 | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample | 30 | TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| @@ -35,6 +35,9 @@ type Provider struct { | |||||
| 35 | TracerProvider *trace.TracerProvider | 35 | TracerProvider *trace.TracerProvider | ||
| 36 | MeterProvider *metric.MeterProvider | 36 | MeterProvider *metric.MeterProvider | ||
| 37 | LogProvider *otellog.LoggerProvider | 37 | LogProvider *otellog.LoggerProvider | ||
| 38 | + | // WorkloadLogs exports proxy access and app container logs, each | |||
| 39 | + | // under its own service identity. Nil when log export is disabled. | |||
| 40 | + | WorkloadLogs *LogExporter | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | // endpointConfig holds parsed endpoint details shared across exporter setup. | 43 | // endpointConfig holds parsed endpoint details shared across exporter setup. | ||
| @@ -218,5 +221,20 @@ func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *re | |||||
| 218 | ) | 221 | ) | ||
| 219 | p.LogProvider = lp | 222 | p.LogProvider = lp | ||
| 220 | *shutdowns = append(*shutdowns, lp.Shutdown) | 223 | *shutdowns = append(*shutdowns, lp.Shutdown) | ||
| 224 | + | ||||
| 225 | + | // Workload logs use a dedicated exporter: the SDK forbids concurrent | |||
| 226 | + | // Export calls, and Gordon's own provider already owns logExp. | |||
| 227 | + | workloadExp, err := otlploghttp.New(ctx, logOpts...) | |||
| 228 | + | if err != nil { | |||
| 229 | + | return fmt.Errorf("create workload log exporter: %w", err) | |||
| 230 | + | } | |||
| 231 | + | // Host and OS only: app records must not inherit Gordon's | |||
| 232 | + | // service.name or service.version. | |||
| 233 | + | base, err := resource.New(ctx, resource.WithOS(), resource.WithHost()) | |||
| 234 | + | if err != nil { | |||
| 235 | + | return fmt.Errorf("create workload log resource: %w", err) | |||
| 236 | + | } | |||
| 237 | + | p.WorkloadLogs = NewLogExporter(workloadExp, base) | |||
| 238 | + | *shutdowns = append(*shutdowns, p.WorkloadLogs.Shutdown) | |||
| 221 | return nil | 239 | return nil | ||
| 222 | } | 240 | } | ||
M internal/app/run.go +49 -0
| @@ -83,6 +83,7 @@ import ( | |||
| 83 | 83 | "github.com/bnema/gordon/internal/usecase/deployment" | |
| 84 | 84 | "github.com/bnema/gordon/internal/usecase/health" | |
| 85 | 85 | "github.com/bnema/gordon/internal/usecase/images" | |
| 86 | + | "github.com/bnema/gordon/internal/usecase/logexport" | |
| 86 | 87 | "github.com/bnema/gordon/internal/usecase/logs" | |
| 87 | 88 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 88 | 89 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| @@ -343,6 +344,9 @@ type services struct { | |||
| 343 | 344 | registryHandler interface { | |
| 344 | 345 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 345 | 346 | } | |
| 347 | + | // workloadLogs exports proxy access and app container logs over | |
| 348 | + | // OTLP. Nil when telemetry log export is disabled. | |
| 349 | + | workloadLogs out.LogExporter | |
| 346 | 350 | } | |
| 347 | 351 | ||
| 348 | 352 | // Run initializes and starts the Gordon application. | |
| @@ -401,6 +405,7 @@ func Run(ctx context.Context, configPath string) error { | |||
| 401 | 405 | if err != nil { | |
| 402 | 406 | return err | |
| 403 | 407 | } | |
| 408 | + | svc.workloadLogs = workloadLogExporter(telProvider) | |
| 404 | 409 | ||
| 405 | 410 | // Register event handlers | |
| 406 | 411 | cleanupHandlers, err := registerEventHandlers(ctx, svc) | |
| @@ -3023,6 +3028,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 3023 | 3028 | if accessWriterConcrete != nil { | |
| 3024 | 3029 | accessWriter = accessWriterConcrete | |
| 3025 | 3030 | } | |
| 3031 | + | accessWriter = withAccessLogExport(svc, accessWriter) | |
| 3026 | 3032 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 3027 | 3033 | defer cancel() | |
| 3028 | 3034 | ||
| @@ -3103,6 +3109,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 3103 | 3109 | // leaves healthy converged apps returning 404 until a later lifecycle | |
| 3104 | 3110 | // operation happens to rebuild it. | |
| 3105 | 3111 | reconcileAppsAtBoot(ctx, svc, log) | |
| 3112 | + | stopLogExport := startContainerLogExport(ctx, svc, log) | |
| 3113 | + | defer stopLogExport() | |
| 3106 | 3114 | ||
| 3107 | 3115 | logEvent := log.Info(). | |
| 3108 | 3116 | Int("proxy_port", cfg.Server.Port). | |
| @@ -3154,6 +3162,47 @@ func reconcileAppsAtBoot(ctx context.Context, svc *services, log zerowrap.Logger | |||
| 3154 | 3162 | svc.appMonitor.Start(ctx) | |
| 3155 | 3163 | } | |
| 3156 | 3164 | ||
| 3165 | + | // workloadLogExporter returns the OTLP workload log exporter, or nil | |
| 3166 | + | // when telemetry log export is disabled. A typed nil must not leak into | |
| 3167 | + | // the out.LogExporter interface. | |
| 3168 | + | func workloadLogExporter(provider *telemetry.Provider) out.LogExporter { | |
| 3169 | + | if provider == nil || provider.WorkloadLogs == nil { | |
| 3170 | + | return nil | |
| 3171 | + | } | |
| 3172 | + | return provider.WorkloadLogs | |
| 3173 | + | } | |
| 3174 | + | ||
| 3175 | + | // withAccessLogExport also exports access entries over OTLP when | |
| 3176 | + | // workload log export is enabled. next may be nil (no local sink). | |
| 3177 | + | func withAccessLogExport(svc *services, next out.AccessLogWriter) out.AccessLogWriter { | |
| 3178 | + | if svc.workloadLogs == nil { | |
| 3179 | + | return next | |
| 3180 | + | } | |
| 3181 | + | return logexport.NewAccessLogExporter(svc.appHostIndex, svc.workloadLogs, next) | |
| 3182 | + | } | |
| 3183 | + | ||
| 3184 | + | // startContainerLogExport follows ACTIVE app containers and exports | |
| 3185 | + | // their output when telemetry log export is enabled. The returned stop | |
| 3186 | + | // function cancels every follower and waits for them, so buffered | |
| 3187 | + | // records reach the exporter before telemetry shuts down. | |
| 3188 | + | func startContainerLogExport(ctx context.Context, svc *services, log zerowrap.Logger) func() { | |
| 3189 | + | if svc.workloadLogs == nil || svc.appState == nil || svc.runtime == nil { | |
| 3190 | + | return func() {} | |
| 3191 | + | } | |
| 3192 | + | ctx, cancel := context.WithCancel(ctx) | |
| 3193 | + | done := make(chan struct{}) | |
| 3194 | + | collector := logexport.NewCollector(svc.appState, svc.runtime, svc.workloadLogs) | |
| 3195 | + | go func() { | |
| 3196 | + | defer close(done) | |
| 3197 | + | collector.Run(ctx) | |
| 3198 | + | }() | |
| 3199 | + | log.Info().Msg("app container log export enabled") | |
| 3200 | + | return func() { | |
| 3201 | + | cancel() | |
| 3202 | + | <-done | |
| 3203 | + | } | |
| 3204 | + | } | |
| 3205 | + | ||
| 3157 | 3206 | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | |
| 3158 | 3207 | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |
| 3159 | 3208 | log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop") | |
| @@ -83,6 +83,7 @@ import ( | |||||
| 83 | "github.com/bnema/gordon/internal/usecase/deployment" | 83 | "github.com/bnema/gordon/internal/usecase/deployment" | ||
| 84 | "github.com/bnema/gordon/internal/usecase/health" | 84 | "github.com/bnema/gordon/internal/usecase/health" | ||
| 85 | "github.com/bnema/gordon/internal/usecase/images" | 85 | "github.com/bnema/gordon/internal/usecase/images" | ||
| 86 | + | "github.com/bnema/gordon/internal/usecase/logexport" | |||
| 86 | "github.com/bnema/gordon/internal/usecase/logs" | 87 | "github.com/bnema/gordon/internal/usecase/logs" | ||
| 87 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | 88 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | ||
| 88 | "github.com/bnema/gordon/internal/usecase/proxy" | 89 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| @@ -343,6 +344,9 @@ type services struct { | |||||
| 343 | registryHandler interface { | 344 | registryHandler interface { | ||
| 344 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 345 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 345 | } | 346 | } | ||
| 347 | + | // workloadLogs exports proxy access and app container logs over | |||
| 348 | + | // OTLP. Nil when telemetry log export is disabled. | |||
| 349 | + | workloadLogs out.LogExporter | |||
| 346 | } | 350 | } | ||
| 347 | 351 | ||||
| 348 | // Run initializes and starts the Gordon application. | 352 | // Run initializes and starts the Gordon application. | ||
| @@ -401,6 +405,7 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 401 | if err != nil { | 405 | if err != nil { | ||
| 402 | return err | 406 | return err | ||
| 403 | } | 407 | } | ||
| 408 | + | svc.workloadLogs = workloadLogExporter(telProvider) | |||
| 404 | 409 | ||||
| 405 | // Register event handlers | 410 | // Register event handlers | ||
| 406 | cleanupHandlers, err := registerEventHandlers(ctx, svc) | 411 | cleanupHandlers, err := registerEventHandlers(ctx, svc) | ||
| @@ -3023,6 +3028,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 3023 | if accessWriterConcrete != nil { | 3028 | if accessWriterConcrete != nil { | ||
| 3024 | accessWriter = accessWriterConcrete | 3029 | accessWriter = accessWriterConcrete | ||
| 3025 | } | 3030 | } | ||
| 3031 | + | accessWriter = withAccessLogExport(svc, accessWriter) | |||
| 3026 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 3032 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 3027 | defer cancel() | 3033 | defer cancel() | ||
| 3028 | 3034 | ||||
| @@ -3103,6 +3109,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 3103 | // leaves healthy converged apps returning 404 until a later lifecycle | 3109 | // leaves healthy converged apps returning 404 until a later lifecycle | ||
| 3104 | // operation happens to rebuild it. | 3110 | // operation happens to rebuild it. | ||
| 3105 | reconcileAppsAtBoot(ctx, svc, log) | 3111 | reconcileAppsAtBoot(ctx, svc, log) | ||
| 3112 | + | stopLogExport := startContainerLogExport(ctx, svc, log) | |||
| 3113 | + | defer stopLogExport() | |||
| 3106 | 3114 | ||||
| 3107 | logEvent := log.Info(). | 3115 | logEvent := log.Info(). | ||
| 3108 | Int("proxy_port", cfg.Server.Port). | 3116 | Int("proxy_port", cfg.Server.Port). | ||
| @@ -3154,6 +3162,47 @@ func reconcileAppsAtBoot(ctx context.Context, svc *services, log zerowrap.Logger | |||||
| 3154 | svc.appMonitor.Start(ctx) | 3162 | svc.appMonitor.Start(ctx) | ||
| 3155 | } | 3163 | } | ||
| 3156 | 3164 | ||||
| 3165 | + | // workloadLogExporter returns the OTLP workload log exporter, or nil | |||
| 3166 | + | // when telemetry log export is disabled. A typed nil must not leak into | |||
| 3167 | + | // the out.LogExporter interface. | |||
| 3168 | + | func workloadLogExporter(provider *telemetry.Provider) out.LogExporter { | |||
| 3169 | + | if provider == nil || provider.WorkloadLogs == nil { | |||
| 3170 | + | return nil | |||
| 3171 | + | } | |||
| 3172 | + | return provider.WorkloadLogs | |||
| 3173 | + | } | |||
| 3174 | + | ||||
| 3175 | + | // withAccessLogExport also exports access entries over OTLP when | |||
| 3176 | + | // workload log export is enabled. next may be nil (no local sink). | |||
| 3177 | + | func withAccessLogExport(svc *services, next out.AccessLogWriter) out.AccessLogWriter { | |||
| 3178 | + | if svc.workloadLogs == nil { | |||
| 3179 | + | return next | |||
| 3180 | + | } | |||
| 3181 | + | return logexport.NewAccessLogExporter(svc.appHostIndex, svc.workloadLogs, next) | |||
| 3182 | + | } | |||
| 3183 | + | ||||
| 3184 | + | // startContainerLogExport follows ACTIVE app containers and exports | |||
| 3185 | + | // their output when telemetry log export is enabled. The returned stop | |||
| 3186 | + | // function cancels every follower and waits for them, so buffered | |||
| 3187 | + | // records reach the exporter before telemetry shuts down. | |||
| 3188 | + | func startContainerLogExport(ctx context.Context, svc *services, log zerowrap.Logger) func() { | |||
| 3189 | + | if svc.workloadLogs == nil || svc.appState == nil || svc.runtime == nil { | |||
| 3190 | + | return func() {} | |||
| 3191 | + | } | |||
| 3192 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 3193 | + | done := make(chan struct{}) | |||
| 3194 | + | collector := logexport.NewCollector(svc.appState, svc.runtime, svc.workloadLogs) | |||
| 3195 | + | go func() { | |||
| 3196 | + | defer close(done) | |||
| 3197 | + | collector.Run(ctx) | |||
| 3198 | + | }() | |||
| 3199 | + | log.Info().Msg("app container log export enabled") | |||
| 3200 | + | return func() { | |||
| 3201 | + | cancel() | |||
| 3202 | + | <-done | |||
| 3203 | + | } | |||
| 3204 | + | } | |||
| 3205 | + | ||||
| 3157 | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | 3206 | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | ||
| 3158 | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | 3207 | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | ||
| 3159 | log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop") | 3208 | log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop") | ||
A internal/boundaries/out/logs.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // LogExporter ships log records to an external log backend (OTLP). | |
| 11 | + | // Export must not block on the network: implementations buffer and | |
| 12 | + | // drop on overload rather than slow down callers. Safe for concurrent use. | |
| 13 | + | type LogExporter interface { | |
| 14 | + | Export(ctx context.Context, record domain.LogRecord) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // ContainerLogStreamer follows the demultiplexed output of one container. | |
| 18 | + | type ContainerLogStreamer interface { | |
| 19 | + | // StreamContainerLogs calls emit for every line emitted after since, | |
| 20 | + | // in order, and blocks until the stream ends (container stopped), | |
| 21 | + | // ctx is canceled, or an error occurs. A missing container reports | |
| 22 | + | // domain.ErrContainerNotFound. | |
| 23 | + | StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // LogExporter ships log records to an external log backend (OTLP). | |||
| 11 | + | // Export must not block on the network: implementations buffer and | |||
| 12 | + | // drop on overload rather than slow down callers. Safe for concurrent use. | |||
| 13 | + | type LogExporter interface { | |||
| 14 | + | Export(ctx context.Context, record domain.LogRecord) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // ContainerLogStreamer follows the demultiplexed output of one container. | |||
| 18 | + | type ContainerLogStreamer interface { | |||
| 19 | + | // StreamContainerLogs calls emit for every line emitted after since, | |||
| 20 | + | // in order, and blocks until the stream ends (container stopped), | |||
| 21 | + | // ctx is canceled, or an error occurs. A missing container reports | |||
| 22 | + | // domain.ErrContainerNotFound. | |||
| 23 | + | StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error | |||
| 24 | + | } | |||
A internal/boundaries/out/metrics.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Metrics records Gordon operational metrics. | |
| 10 | + | // Implementations must be safe for concurrent use. | |
| 11 | + | type Metrics interface { | |
| 12 | + | // RecordImagePush counts one stored manifest and its size in bytes. | |
| 13 | + | RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) | |
| 14 | + | // RecordEventProcessed counts one event handled successfully. | |
| 15 | + | RecordEventProcessed(ctx context.Context, eventType domain.EventType) | |
| 16 | + | // RecordEventDropped counts one event dropped because the bus was full. | |
| 17 | + | RecordEventDropped(ctx context.Context, eventType domain.EventType) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Metrics records Gordon operational metrics. | |||
| 10 | + | // Implementations must be safe for concurrent use. | |||
| 11 | + | type Metrics interface { | |||
| 12 | + | // RecordImagePush counts one stored manifest and its size in bytes. | |||
| 13 | + | RecordImagePush(ctx context.Context, name, reference string, sizeBytes int64) | |||
| 14 | + | // RecordEventProcessed counts one event handled successfully. | |||
| 15 | + | RecordEventProcessed(ctx context.Context, eventType domain.EventType) | |||
| 16 | + | // RecordEventDropped counts one event dropped because the bus was full. | |||
| 17 | + | RecordEventDropped(ctx context.Context, eventType domain.EventType) | |||
| 18 | + | } | |||
A internal/boundaries/out/mocks/mock_container_log_streamer.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMockContainerLogStreamer creates a new instance of MockContainerLogStreamer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMockContainerLogStreamer(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MockContainerLogStreamer { | |
| 21 | + | mock := &MockContainerLogStreamer{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 25 | + | ||
| 26 | + | return mock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // MockContainerLogStreamer is an autogenerated mock type for the ContainerLogStreamer type | |
| 30 | + | type MockContainerLogStreamer struct { | |
| 31 | + | mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type MockContainerLogStreamer_Expecter struct { | |
| 35 | + | mock *mock.Mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (_m *MockContainerLogStreamer) EXPECT() *MockContainerLogStreamer_Expecter { | |
| 39 | + | return &MockContainerLogStreamer_Expecter{mock: &_m.Mock} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // StreamContainerLogs provides a mock function for the type MockContainerLogStreamer | |
| 43 | + | func (_mock *MockContainerLogStreamer) StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error { | |
| 44 | + | ret := _mock.Called(ctx, containerID, since, emit) | |
| 45 | + | ||
| 46 | + | if len(ret) == 0 { | |
| 47 | + | panic("no return value specified for StreamContainerLogs") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var r0 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, func(domain.ContainerLogLine)) error); ok { | |
| 52 | + | r0 = returnFunc(ctx, containerID, since, emit) | |
| 53 | + | } else { | |
| 54 | + | r0 = ret.Error(0) | |
| 55 | + | } | |
| 56 | + | return r0 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // MockContainerLogStreamer_StreamContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamContainerLogs' | |
| 60 | + | type MockContainerLogStreamer_StreamContainerLogs_Call struct { | |
| 61 | + | *mock.Call | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // StreamContainerLogs is a helper method to define mock.On call | |
| 65 | + | // - ctx context.Context | |
| 66 | + | // - containerID string | |
| 67 | + | // - since time.Time | |
| 68 | + | // - emit func(domain.ContainerLogLine) | |
| 69 | + | func (_e *MockContainerLogStreamer_Expecter) StreamContainerLogs(ctx any, containerID any, since any, emit any) *MockContainerLogStreamer_StreamContainerLogs_Call { | |
| 70 | + | return &MockContainerLogStreamer_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, containerID, since, emit)} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) Run(run func(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine))) *MockContainerLogStreamer_StreamContainerLogs_Call { | |
| 74 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 75 | + | var arg0 context.Context | |
| 76 | + | if args[0] != nil { | |
| 77 | + | arg0 = args[0].(context.Context) | |
| 78 | + | } | |
| 79 | + | var arg1 string | |
| 80 | + | if args[1] != nil { | |
| 81 | + | arg1 = args[1].(string) | |
| 82 | + | } | |
| 83 | + | var arg2 time.Time | |
| 84 | + | if args[2] != nil { | |
| 85 | + | arg2 = args[2].(time.Time) | |
| 86 | + | } | |
| 87 | + | var arg3 func(domain.ContainerLogLine) | |
| 88 | + | if args[3] != nil { | |
| 89 | + | arg3 = args[3].(func(domain.ContainerLogLine)) | |
| 90 | + | } | |
| 91 | + | run( | |
| 92 | + | arg0, | |
| 93 | + | arg1, | |
| 94 | + | arg2, | |
| 95 | + | arg3, | |
| 96 | + | ) | |
| 97 | + | }) | |
| 98 | + | return _c | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) Return(err error) *MockContainerLogStreamer_StreamContainerLogs_Call { | |
| 102 | + | _c.Call.Return(err) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) RunAndReturn(run func(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error) *MockContainerLogStreamer_StreamContainerLogs_Call { | |
| 107 | + | _c.Call.Return(run) | |
| 108 | + | return _c | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewMockContainerLogStreamer creates a new instance of MockContainerLogStreamer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewMockContainerLogStreamer(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *MockContainerLogStreamer { | |||
| 21 | + | mock := &MockContainerLogStreamer{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 25 | + | ||||
| 26 | + | return mock | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // MockContainerLogStreamer is an autogenerated mock type for the ContainerLogStreamer type | |||
| 30 | + | type MockContainerLogStreamer struct { | |||
| 31 | + | mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type MockContainerLogStreamer_Expecter struct { | |||
| 35 | + | mock *mock.Mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (_m *MockContainerLogStreamer) EXPECT() *MockContainerLogStreamer_Expecter { | |||
| 39 | + | return &MockContainerLogStreamer_Expecter{mock: &_m.Mock} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // StreamContainerLogs provides a mock function for the type MockContainerLogStreamer | |||
| 43 | + | func (_mock *MockContainerLogStreamer) StreamContainerLogs(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error { | |||
| 44 | + | ret := _mock.Called(ctx, containerID, since, emit) | |||
| 45 | + | ||||
| 46 | + | if len(ret) == 0 { | |||
| 47 | + | panic("no return value specified for StreamContainerLogs") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | var r0 error | |||
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, func(domain.ContainerLogLine)) error); ok { | |||
| 52 | + | r0 = returnFunc(ctx, containerID, since, emit) | |||
| 53 | + | } else { | |||
| 54 | + | r0 = ret.Error(0) | |||
| 55 | + | } | |||
| 56 | + | return r0 | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // MockContainerLogStreamer_StreamContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamContainerLogs' | |||
| 60 | + | type MockContainerLogStreamer_StreamContainerLogs_Call struct { | |||
| 61 | + | *mock.Call | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // StreamContainerLogs is a helper method to define mock.On call | |||
| 65 | + | // - ctx context.Context | |||
| 66 | + | // - containerID string | |||
| 67 | + | // - since time.Time | |||
| 68 | + | // - emit func(domain.ContainerLogLine) | |||
| 69 | + | func (_e *MockContainerLogStreamer_Expecter) StreamContainerLogs(ctx any, containerID any, since any, emit any) *MockContainerLogStreamer_StreamContainerLogs_Call { | |||
| 70 | + | return &MockContainerLogStreamer_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, containerID, since, emit)} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) Run(run func(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine))) *MockContainerLogStreamer_StreamContainerLogs_Call { | |||
| 74 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 75 | + | var arg0 context.Context | |||
| 76 | + | if args[0] != nil { | |||
| 77 | + | arg0 = args[0].(context.Context) | |||
| 78 | + | } | |||
| 79 | + | var arg1 string | |||
| 80 | + | if args[1] != nil { | |||
| 81 | + | arg1 = args[1].(string) | |||
| 82 | + | } | |||
| 83 | + | var arg2 time.Time | |||
| 84 | + | if args[2] != nil { | |||
| 85 | + | arg2 = args[2].(time.Time) | |||
| 86 | + | } | |||
| 87 | + | var arg3 func(domain.ContainerLogLine) | |||
| 88 | + | if args[3] != nil { | |||
| 89 | + | arg3 = args[3].(func(domain.ContainerLogLine)) | |||
| 90 | + | } | |||
| 91 | + | run( | |||
| 92 | + | arg0, | |||
| 93 | + | arg1, | |||
| 94 | + | arg2, | |||
| 95 | + | arg3, | |||
| 96 | + | ) | |||
| 97 | + | }) | |||
| 98 | + | return _c | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) Return(err error) *MockContainerLogStreamer_StreamContainerLogs_Call { | |||
| 102 | + | _c.Call.Return(err) | |||
| 103 | + | return _c | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (_c *MockContainerLogStreamer_StreamContainerLogs_Call) RunAndReturn(run func(ctx context.Context, containerID string, since time.Time, emit func(domain.ContainerLogLine)) error) *MockContainerLogStreamer_StreamContainerLogs_Call { | |||
| 107 | + | _c.Call.Return(run) | |||
| 108 | + | return _c | |||
| 109 | + | } | |||
A internal/boundaries/out/mocks/mock_log_exporter.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockLogExporter creates a new instance of MockLogExporter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockLogExporter(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockLogExporter { | |
| 20 | + | mock := &MockLogExporter{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockLogExporter is an autogenerated mock type for the LogExporter type | |
| 29 | + | type MockLogExporter struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockLogExporter_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockLogExporter) EXPECT() *MockLogExporter_Expecter { | |
| 38 | + | return &MockLogExporter_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Export provides a mock function for the type MockLogExporter | |
| 42 | + | func (_mock *MockLogExporter) Export(ctx context.Context, record domain.LogRecord) { | |
| 43 | + | _mock.Called(ctx, record) | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // MockLogExporter_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' | |
| 48 | + | type MockLogExporter_Export_Call struct { | |
| 49 | + | *mock.Call | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Export is a helper method to define mock.On call | |
| 53 | + | // - ctx context.Context | |
| 54 | + | // - record domain.LogRecord | |
| 55 | + | func (_e *MockLogExporter_Expecter) Export(ctx any, record any) *MockLogExporter_Export_Call { | |
| 56 | + | return &MockLogExporter_Export_Call{Call: _e.mock.On("Export", ctx, record)} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (_c *MockLogExporter_Export_Call) Run(run func(ctx context.Context, record domain.LogRecord)) *MockLogExporter_Export_Call { | |
| 60 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 61 | + | var arg0 context.Context | |
| 62 | + | if args[0] != nil { | |
| 63 | + | arg0 = args[0].(context.Context) | |
| 64 | + | } | |
| 65 | + | var arg1 domain.LogRecord | |
| 66 | + | if args[1] != nil { | |
| 67 | + | arg1 = args[1].(domain.LogRecord) | |
| 68 | + | } | |
| 69 | + | run( | |
| 70 | + | arg0, | |
| 71 | + | arg1, | |
| 72 | + | ) | |
| 73 | + | }) | |
| 74 | + | return _c | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (_c *MockLogExporter_Export_Call) Return() *MockLogExporter_Export_Call { | |
| 78 | + | _c.Call.Return() | |
| 79 | + | return _c | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockLogExporter_Export_Call) RunAndReturn(run func(ctx context.Context, record domain.LogRecord)) *MockLogExporter_Export_Call { | |
| 83 | + | _c.Run(run) | |
| 84 | + | return _c | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMockLogExporter creates a new instance of MockLogExporter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewMockLogExporter(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MockLogExporter { | |||
| 20 | + | mock := &MockLogExporter{} | |||
| 21 | + | mock.Mock.Test(t) | |||
| 22 | + | ||||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 24 | + | ||||
| 25 | + | return mock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MockLogExporter is an autogenerated mock type for the LogExporter type | |||
| 29 | + | type MockLogExporter struct { | |||
| 30 | + | mock.Mock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type MockLogExporter_Expecter struct { | |||
| 34 | + | mock *mock.Mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (_m *MockLogExporter) EXPECT() *MockLogExporter_Expecter { | |||
| 38 | + | return &MockLogExporter_Expecter{mock: &_m.Mock} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Export provides a mock function for the type MockLogExporter | |||
| 42 | + | func (_mock *MockLogExporter) Export(ctx context.Context, record domain.LogRecord) { | |||
| 43 | + | _mock.Called(ctx, record) | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // MockLogExporter_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' | |||
| 48 | + | type MockLogExporter_Export_Call struct { | |||
| 49 | + | *mock.Call | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Export is a helper method to define mock.On call | |||
| 53 | + | // - ctx context.Context | |||
| 54 | + | // - record domain.LogRecord | |||
| 55 | + | func (_e *MockLogExporter_Expecter) Export(ctx any, record any) *MockLogExporter_Export_Call { | |||
| 56 | + | return &MockLogExporter_Export_Call{Call: _e.mock.On("Export", ctx, record)} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (_c *MockLogExporter_Export_Call) Run(run func(ctx context.Context, record domain.LogRecord)) *MockLogExporter_Export_Call { | |||
| 60 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 61 | + | var arg0 context.Context | |||
| 62 | + | if args[0] != nil { | |||
| 63 | + | arg0 = args[0].(context.Context) | |||
| 64 | + | } | |||
| 65 | + | var arg1 domain.LogRecord | |||
| 66 | + | if args[1] != nil { | |||
| 67 | + | arg1 = args[1].(domain.LogRecord) | |||
| 68 | + | } | |||
| 69 | + | run( | |||
| 70 | + | arg0, | |||
| 71 | + | arg1, | |||
| 72 | + | ) | |||
| 73 | + | }) | |||
| 74 | + | return _c | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (_c *MockLogExporter_Export_Call) Return() *MockLogExporter_Export_Call { | |||
| 78 | + | _c.Call.Return() | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockLogExporter_Export_Call) RunAndReturn(run func(ctx context.Context, record domain.LogRecord)) *MockLogExporter_Export_Call { | |||
| 83 | + | _c.Run(run) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
A internal/boundaries/out/mocks/mock_metrics.go +189 -0
| @@ -0,0 +1,189 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockMetrics creates a new instance of MockMetrics. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockMetrics(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockMetrics { | |
| 20 | + | mock := &MockMetrics{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockMetrics is an autogenerated mock type for the Metrics type | |
| 29 | + | type MockMetrics struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockMetrics_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockMetrics) EXPECT() *MockMetrics_Expecter { | |
| 38 | + | return &MockMetrics_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // RecordEventDropped provides a mock function for the type MockMetrics | |
| 42 | + | func (_mock *MockMetrics) RecordEventDropped(ctx context.Context, eventType domain.EventType) { | |
| 43 | + | _mock.Called(ctx, eventType) | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // MockMetrics_RecordEventDropped_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordEventDropped' | |
| 48 | + | type MockMetrics_RecordEventDropped_Call struct { | |
| 49 | + | *mock.Call | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // RecordEventDropped is a helper method to define mock.On call | |
| 53 | + | // - ctx context.Context | |
| 54 | + | // - eventType domain.EventType | |
| 55 | + | func (_e *MockMetrics_Expecter) RecordEventDropped(ctx any, eventType any) *MockMetrics_RecordEventDropped_Call { | |
| 56 | + | return &MockMetrics_RecordEventDropped_Call{Call: _e.mock.On("RecordEventDropped", ctx, eventType)} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (_c *MockMetrics_RecordEventDropped_Call) Run(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventDropped_Call { | |
| 60 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 61 | + | var arg0 context.Context | |
| 62 | + | if args[0] != nil { | |
| 63 | + | arg0 = args[0].(context.Context) | |
| 64 | + | } | |
| 65 | + | var arg1 domain.EventType | |
| 66 | + | if args[1] != nil { | |
| 67 | + | arg1 = args[1].(domain.EventType) | |
| 68 | + | } | |
| 69 | + | run( | |
| 70 | + | arg0, | |
| 71 | + | arg1, | |
| 72 | + | ) | |
| 73 | + | }) | |
| 74 | + | return _c | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (_c *MockMetrics_RecordEventDropped_Call) Return() *MockMetrics_RecordEventDropped_Call { | |
| 78 | + | _c.Call.Return() | |
| 79 | + | return _c | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockMetrics_RecordEventDropped_Call) RunAndReturn(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventDropped_Call { | |
| 83 | + | _c.Run(run) | |
| 84 | + | return _c | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // RecordEventProcessed provides a mock function for the type MockMetrics | |
| 88 | + | func (_mock *MockMetrics) RecordEventProcessed(ctx context.Context, eventType domain.EventType) { | |
| 89 | + | _mock.Called(ctx, eventType) | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // MockMetrics_RecordEventProcessed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordEventProcessed' | |
| 94 | + | type MockMetrics_RecordEventProcessed_Call struct { | |
| 95 | + | *mock.Call | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // RecordEventProcessed is a helper method to define mock.On call | |
| 99 | + | // - ctx context.Context | |
| 100 | + | // - eventType domain.EventType | |
| 101 | + | func (_e *MockMetrics_Expecter) RecordEventProcessed(ctx any, eventType any) *MockMetrics_RecordEventProcessed_Call { | |
| 102 | + | return &MockMetrics_RecordEventProcessed_Call{Call: _e.mock.On("RecordEventProcessed", ctx, eventType)} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (_c *MockMetrics_RecordEventProcessed_Call) Run(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventProcessed_Call { | |
| 106 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 107 | + | var arg0 context.Context | |
| 108 | + | if args[0] != nil { | |
| 109 | + | arg0 = args[0].(context.Context) | |
| 110 | + | } | |
| 111 | + | var arg1 domain.EventType | |
| 112 | + | if args[1] != nil { | |
| 113 | + | arg1 = args[1].(domain.EventType) | |
| 114 | + | } | |
| 115 | + | run( | |
| 116 | + | arg0, | |
| 117 | + | arg1, | |
| 118 | + | ) | |
| 119 | + | }) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (_c *MockMetrics_RecordEventProcessed_Call) Return() *MockMetrics_RecordEventProcessed_Call { | |
| 124 | + | _c.Call.Return() | |
| 125 | + | return _c | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (_c *MockMetrics_RecordEventProcessed_Call) RunAndReturn(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventProcessed_Call { | |
| 129 | + | _c.Run(run) | |
| 130 | + | return _c | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // RecordImagePush provides a mock function for the type MockMetrics | |
| 134 | + | func (_mock *MockMetrics) RecordImagePush(ctx context.Context, name string, reference string, sizeBytes int64) { | |
| 135 | + | _mock.Called(ctx, name, reference, sizeBytes) | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // MockMetrics_RecordImagePush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordImagePush' | |
| 140 | + | type MockMetrics_RecordImagePush_Call struct { | |
| 141 | + | *mock.Call | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // RecordImagePush is a helper method to define mock.On call | |
| 145 | + | // - ctx context.Context | |
| 146 | + | // - name string | |
| 147 | + | // - reference string | |
| 148 | + | // - sizeBytes int64 | |
| 149 | + | func (_e *MockMetrics_Expecter) RecordImagePush(ctx any, name any, reference any, sizeBytes any) *MockMetrics_RecordImagePush_Call { | |
| 150 | + | return &MockMetrics_RecordImagePush_Call{Call: _e.mock.On("RecordImagePush", ctx, name, reference, sizeBytes)} | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (_c *MockMetrics_RecordImagePush_Call) Run(run func(ctx context.Context, name string, reference string, sizeBytes int64)) *MockMetrics_RecordImagePush_Call { | |
| 154 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 155 | + | var arg0 context.Context | |
| 156 | + | if args[0] != nil { | |
| 157 | + | arg0 = args[0].(context.Context) | |
| 158 | + | } | |
| 159 | + | var arg1 string | |
| 160 | + | if args[1] != nil { | |
| 161 | + | arg1 = args[1].(string) | |
| 162 | + | } | |
| 163 | + | var arg2 string | |
| 164 | + | if args[2] != nil { | |
| 165 | + | arg2 = args[2].(string) | |
| 166 | + | } | |
| 167 | + | var arg3 int64 | |
| 168 | + | if args[3] != nil { | |
| 169 | + | arg3 = args[3].(int64) | |
| 170 | + | } | |
| 171 | + | run( | |
| 172 | + | arg0, | |
| 173 | + | arg1, | |
| 174 | + | arg2, | |
| 175 | + | arg3, | |
| 176 | + | ) | |
| 177 | + | }) | |
| 178 | + | return _c | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (_c *MockMetrics_RecordImagePush_Call) Return() *MockMetrics_RecordImagePush_Call { | |
| 182 | + | _c.Call.Return() | |
| 183 | + | return _c | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (_c *MockMetrics_RecordImagePush_Call) RunAndReturn(run func(ctx context.Context, name string, reference string, sizeBytes int64)) *MockMetrics_RecordImagePush_Call { | |
| 187 | + | _c.Run(run) | |
| 188 | + | return _c | |
| 189 | + | } | |
| @@ -0,0 +1,189 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMockMetrics creates a new instance of MockMetrics. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewMockMetrics(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MockMetrics { | |||
| 20 | + | mock := &MockMetrics{} | |||
| 21 | + | mock.Mock.Test(t) | |||
| 22 | + | ||||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 24 | + | ||||
| 25 | + | return mock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MockMetrics is an autogenerated mock type for the Metrics type | |||
| 29 | + | type MockMetrics struct { | |||
| 30 | + | mock.Mock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type MockMetrics_Expecter struct { | |||
| 34 | + | mock *mock.Mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (_m *MockMetrics) EXPECT() *MockMetrics_Expecter { | |||
| 38 | + | return &MockMetrics_Expecter{mock: &_m.Mock} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // RecordEventDropped provides a mock function for the type MockMetrics | |||
| 42 | + | func (_mock *MockMetrics) RecordEventDropped(ctx context.Context, eventType domain.EventType) { | |||
| 43 | + | _mock.Called(ctx, eventType) | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // MockMetrics_RecordEventDropped_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordEventDropped' | |||
| 48 | + | type MockMetrics_RecordEventDropped_Call struct { | |||
| 49 | + | *mock.Call | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // RecordEventDropped is a helper method to define mock.On call | |||
| 53 | + | // - ctx context.Context | |||
| 54 | + | // - eventType domain.EventType | |||
| 55 | + | func (_e *MockMetrics_Expecter) RecordEventDropped(ctx any, eventType any) *MockMetrics_RecordEventDropped_Call { | |||
| 56 | + | return &MockMetrics_RecordEventDropped_Call{Call: _e.mock.On("RecordEventDropped", ctx, eventType)} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (_c *MockMetrics_RecordEventDropped_Call) Run(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventDropped_Call { | |||
| 60 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 61 | + | var arg0 context.Context | |||
| 62 | + | if args[0] != nil { | |||
| 63 | + | arg0 = args[0].(context.Context) | |||
| 64 | + | } | |||
| 65 | + | var arg1 domain.EventType | |||
| 66 | + | if args[1] != nil { | |||
| 67 | + | arg1 = args[1].(domain.EventType) | |||
| 68 | + | } | |||
| 69 | + | run( | |||
| 70 | + | arg0, | |||
| 71 | + | arg1, | |||
| 72 | + | ) | |||
| 73 | + | }) | |||
| 74 | + | return _c | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (_c *MockMetrics_RecordEventDropped_Call) Return() *MockMetrics_RecordEventDropped_Call { | |||
| 78 | + | _c.Call.Return() | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockMetrics_RecordEventDropped_Call) RunAndReturn(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventDropped_Call { | |||
| 83 | + | _c.Run(run) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // RecordEventProcessed provides a mock function for the type MockMetrics | |||
| 88 | + | func (_mock *MockMetrics) RecordEventProcessed(ctx context.Context, eventType domain.EventType) { | |||
| 89 | + | _mock.Called(ctx, eventType) | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // MockMetrics_RecordEventProcessed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordEventProcessed' | |||
| 94 | + | type MockMetrics_RecordEventProcessed_Call struct { | |||
| 95 | + | *mock.Call | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // RecordEventProcessed is a helper method to define mock.On call | |||
| 99 | + | // - ctx context.Context | |||
| 100 | + | // - eventType domain.EventType | |||
| 101 | + | func (_e *MockMetrics_Expecter) RecordEventProcessed(ctx any, eventType any) *MockMetrics_RecordEventProcessed_Call { | |||
| 102 | + | return &MockMetrics_RecordEventProcessed_Call{Call: _e.mock.On("RecordEventProcessed", ctx, eventType)} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (_c *MockMetrics_RecordEventProcessed_Call) Run(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventProcessed_Call { | |||
| 106 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 107 | + | var arg0 context.Context | |||
| 108 | + | if args[0] != nil { | |||
| 109 | + | arg0 = args[0].(context.Context) | |||
| 110 | + | } | |||
| 111 | + | var arg1 domain.EventType | |||
| 112 | + | if args[1] != nil { | |||
| 113 | + | arg1 = args[1].(domain.EventType) | |||
| 114 | + | } | |||
| 115 | + | run( | |||
| 116 | + | arg0, | |||
| 117 | + | arg1, | |||
| 118 | + | ) | |||
| 119 | + | }) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (_c *MockMetrics_RecordEventProcessed_Call) Return() *MockMetrics_RecordEventProcessed_Call { | |||
| 124 | + | _c.Call.Return() | |||
| 125 | + | return _c | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (_c *MockMetrics_RecordEventProcessed_Call) RunAndReturn(run func(ctx context.Context, eventType domain.EventType)) *MockMetrics_RecordEventProcessed_Call { | |||
| 129 | + | _c.Run(run) | |||
| 130 | + | return _c | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // RecordImagePush provides a mock function for the type MockMetrics | |||
| 134 | + | func (_mock *MockMetrics) RecordImagePush(ctx context.Context, name string, reference string, sizeBytes int64) { | |||
| 135 | + | _mock.Called(ctx, name, reference, sizeBytes) | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // MockMetrics_RecordImagePush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordImagePush' | |||
| 140 | + | type MockMetrics_RecordImagePush_Call struct { | |||
| 141 | + | *mock.Call | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // RecordImagePush is a helper method to define mock.On call | |||
| 145 | + | // - ctx context.Context | |||
| 146 | + | // - name string | |||
| 147 | + | // - reference string | |||
| 148 | + | // - sizeBytes int64 | |||
| 149 | + | func (_e *MockMetrics_Expecter) RecordImagePush(ctx any, name any, reference any, sizeBytes any) *MockMetrics_RecordImagePush_Call { | |||
| 150 | + | return &MockMetrics_RecordImagePush_Call{Call: _e.mock.On("RecordImagePush", ctx, name, reference, sizeBytes)} | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (_c *MockMetrics_RecordImagePush_Call) Run(run func(ctx context.Context, name string, reference string, sizeBytes int64)) *MockMetrics_RecordImagePush_Call { | |||
| 154 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 155 | + | var arg0 context.Context | |||
| 156 | + | if args[0] != nil { | |||
| 157 | + | arg0 = args[0].(context.Context) | |||
| 158 | + | } | |||
| 159 | + | var arg1 string | |||
| 160 | + | if args[1] != nil { | |||
| 161 | + | arg1 = args[1].(string) | |||
| 162 | + | } | |||
| 163 | + | var arg2 string | |||
| 164 | + | if args[2] != nil { | |||
| 165 | + | arg2 = args[2].(string) | |||
| 166 | + | } | |||
| 167 | + | var arg3 int64 | |||
| 168 | + | if args[3] != nil { | |||
| 169 | + | arg3 = args[3].(int64) | |||
| 170 | + | } | |||
| 171 | + | run( | |||
| 172 | + | arg0, | |||
| 173 | + | arg1, | |||
| 174 | + | arg2, | |||
| 175 | + | arg3, | |||
| 176 | + | ) | |||
| 177 | + | }) | |||
| 178 | + | return _c | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (_c *MockMetrics_RecordImagePush_Call) Return() *MockMetrics_RecordImagePush_Call { | |||
| 182 | + | _c.Call.Return() | |||
| 183 | + | return _c | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (_c *MockMetrics_RecordImagePush_Call) RunAndReturn(run func(ctx context.Context, name string, reference string, sizeBytes int64)) *MockMetrics_RecordImagePush_Call { | |||
| 187 | + | _c.Run(run) | |||
| 188 | + | return _c | |||
| 189 | + | } | |||
M internal/domain/app_spec.go +15 -0
| @@ -111,6 +111,12 @@ type AppService struct { | |||
| 111 | 111 | Devices []string | |
| 112 | 112 | Databases []AppDatabase | |
| 113 | 113 | Backup AppBackup | |
| 114 | + | // LogExportDisabled opts the service out of OTLP log export. The | |
| 115 | + | // manifest resolves it from [services.<name>.telemetry] logs, then | |
| 116 | + | // the app-level [telemetry] logs, then the default (export on). The | |
| 117 | + | // zero value keeps export enabled for states persisted before the | |
| 118 | + | // field existed. | |
| 119 | + | LogExportDisabled bool | |
| 114 | 120 | } | |
| 115 | 121 | ||
| 116 | 122 | // AppReadiness is the explicit readiness check for a service. | |
| @@ -1065,12 +1071,21 @@ func diffService(name string, desired, effective AppService) []string { | |||
| 1065 | 1071 | changed = append(changed, "service/"+name+"/binds") | |
| 1066 | 1072 | } | |
| 1067 | 1073 | changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices) | |
| 1074 | + | return appendOperationChanges(changed, name, desired, effective) | |
| 1075 | + | } | |
| 1076 | + | ||
| 1077 | + | // appendOperationChanges compares the operational (non-runtime) service | |
| 1078 | + | // settings: databases, backups, and telemetry. | |
| 1079 | + | func appendOperationChanges(changed []string, name string, desired, effective AppService) []string { | |
| 1068 | 1080 | if !reflect.DeepEqual(desired.Databases, effective.Databases) { | |
| 1069 | 1081 | changed = append(changed, "service/"+name+"/databases") | |
| 1070 | 1082 | } | |
| 1071 | 1083 | if !reflect.DeepEqual(desired.Backup, effective.Backup) { | |
| 1072 | 1084 | changed = append(changed, "service/"+name+"/backup") | |
| 1073 | 1085 | } | |
| 1086 | + | if desired.LogExportDisabled != effective.LogExportDisabled { | |
| 1087 | + | changed = append(changed, "service/"+name+"/telemetry") | |
| 1088 | + | } | |
| 1074 | 1089 | return changed | |
| 1075 | 1090 | } | |
| 1076 | 1091 | ||
| @@ -111,6 +111,12 @@ type AppService struct { | |||||
| 111 | Devices []string | 111 | Devices []string | ||
| 112 | Databases []AppDatabase | 112 | Databases []AppDatabase | ||
| 113 | Backup AppBackup | 113 | Backup AppBackup | ||
| 114 | + | // LogExportDisabled opts the service out of OTLP log export. The | |||
| 115 | + | // manifest resolves it from [services.<name>.telemetry] logs, then | |||
| 116 | + | // the app-level [telemetry] logs, then the default (export on). The | |||
| 117 | + | // zero value keeps export enabled for states persisted before the | |||
| 118 | + | // field existed. | |||
| 119 | + | LogExportDisabled bool | |||
| 114 | } | 120 | } | ||
| 115 | 121 | ||||
| 116 | // AppReadiness is the explicit readiness check for a service. | 122 | // AppReadiness is the explicit readiness check for a service. | ||
| @@ -1065,12 +1071,21 @@ func diffService(name string, desired, effective AppService) []string { | |||||
| 1065 | changed = append(changed, "service/"+name+"/binds") | 1071 | changed = append(changed, "service/"+name+"/binds") | ||
| 1066 | } | 1072 | } | ||
| 1067 | changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices) | 1073 | changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices) | ||
| 1074 | + | return appendOperationChanges(changed, name, desired, effective) | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1077 | + | // appendOperationChanges compares the operational (non-runtime) service | |||
| 1078 | + | // settings: databases, backups, and telemetry. | |||
| 1079 | + | func appendOperationChanges(changed []string, name string, desired, effective AppService) []string { | |||
| 1068 | if !reflect.DeepEqual(desired.Databases, effective.Databases) { | 1080 | if !reflect.DeepEqual(desired.Databases, effective.Databases) { | ||
| 1069 | changed = append(changed, "service/"+name+"/databases") | 1081 | changed = append(changed, "service/"+name+"/databases") | ||
| 1070 | } | 1082 | } | ||
| 1071 | if !reflect.DeepEqual(desired.Backup, effective.Backup) { | 1083 | if !reflect.DeepEqual(desired.Backup, effective.Backup) { | ||
| 1072 | changed = append(changed, "service/"+name+"/backup") | 1084 | changed = append(changed, "service/"+name+"/backup") | ||
| 1073 | } | 1085 | } | ||
| 1086 | + | if desired.LogExportDisabled != effective.LogExportDisabled { | |||
| 1087 | + | changed = append(changed, "service/"+name+"/telemetry") | |||
| 1088 | + | } | |||
| 1074 | return changed | 1089 | return changed | ||
| 1075 | } | 1090 | } | ||
| 1076 | 1091 | ||||
A internal/domain/logs.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // LogSource identifies the workload that emitted a log record. | |
| 6 | + | // A zero LogSource is Gordon itself. | |
| 7 | + | type LogSource struct { | |
| 8 | + | App string | |
| 9 | + | Service string | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // IsGordon reports whether the record belongs to Gordon itself. | |
| 13 | + | func (s LogSource) IsGordon() bool { | |
| 14 | + | return s.App == "" | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // ServiceName is the unique exported service name, "<app>.<service>". | |
| 18 | + | // Service names alone collide across apps (many apps have "web"). | |
| 19 | + | func (s LogSource) ServiceName() string { | |
| 20 | + | if s.IsGordon() { | |
| 21 | + | return "gordon" | |
| 22 | + | } | |
| 23 | + | return s.App + "." + s.Service | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Log stream names for container output. | |
| 27 | + | const ( | |
| 28 | + | LogStreamStdout = "stdout" | |
| 29 | + | LogStreamStderr = "stderr" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Log types distinguish record families sharing one source. | |
| 33 | + | const ( | |
| 34 | + | LogTypeContainer = "container" | |
| 35 | + | LogTypeAccess = "access" | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | // LogSeverity is a coarse, exporter-neutral severity. | |
| 39 | + | type LogSeverity int | |
| 40 | + | ||
| 41 | + | // Severities. The zero value means unspecified. | |
| 42 | + | const ( | |
| 43 | + | LogSeverityUnspecified LogSeverity = iota | |
| 44 | + | LogSeverityInfo | |
| 45 | + | LogSeverityWarn | |
| 46 | + | LogSeverityError | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // ContainerLogLine is one demultiplexed line of container output. | |
| 50 | + | type ContainerLogLine struct { | |
| 51 | + | Time time.Time | |
| 52 | + | Stream string | |
| 53 | + | Body string | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // LogRecord is one exported log record. | |
| 57 | + | type LogRecord struct { | |
| 58 | + | Time time.Time | |
| 59 | + | Source LogSource | |
| 60 | + | Type string | |
| 61 | + | Severity LogSeverity | |
| 62 | + | Body string | |
| 63 | + | // Stream is stdout or stderr for container output, empty otherwise. | |
| 64 | + | Stream string | |
| 65 | + | // Attributes carry per-record metadata (status, path...). | |
| 66 | + | Attributes map[string]string | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // LogSource identifies the workload that emitted a log record. | |||
| 6 | + | // A zero LogSource is Gordon itself. | |||
| 7 | + | type LogSource struct { | |||
| 8 | + | App string | |||
| 9 | + | Service string | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // IsGordon reports whether the record belongs to Gordon itself. | |||
| 13 | + | func (s LogSource) IsGordon() bool { | |||
| 14 | + | return s.App == "" | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // ServiceName is the unique exported service name, "<app>.<service>". | |||
| 18 | + | // Service names alone collide across apps (many apps have "web"). | |||
| 19 | + | func (s LogSource) ServiceName() string { | |||
| 20 | + | if s.IsGordon() { | |||
| 21 | + | return "gordon" | |||
| 22 | + | } | |||
| 23 | + | return s.App + "." + s.Service | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Log stream names for container output. | |||
| 27 | + | const ( | |||
| 28 | + | LogStreamStdout = "stdout" | |||
| 29 | + | LogStreamStderr = "stderr" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // Log types distinguish record families sharing one source. | |||
| 33 | + | const ( | |||
| 34 | + | LogTypeContainer = "container" | |||
| 35 | + | LogTypeAccess = "access" | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | // LogSeverity is a coarse, exporter-neutral severity. | |||
| 39 | + | type LogSeverity int | |||
| 40 | + | ||||
| 41 | + | // Severities. The zero value means unspecified. | |||
| 42 | + | const ( | |||
| 43 | + | LogSeverityUnspecified LogSeverity = iota | |||
| 44 | + | LogSeverityInfo | |||
| 45 | + | LogSeverityWarn | |||
| 46 | + | LogSeverityError | |||
| 47 | + | ) | |||
| 48 | + | ||||
| 49 | + | // ContainerLogLine is one demultiplexed line of container output. | |||
| 50 | + | type ContainerLogLine struct { | |||
| 51 | + | Time time.Time | |||
| 52 | + | Stream string | |||
| 53 | + | Body string | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // LogRecord is one exported log record. | |||
| 57 | + | type LogRecord struct { | |||
| 58 | + | Time time.Time | |||
| 59 | + | Source LogSource | |||
| 60 | + | Type string | |||
| 61 | + | Severity LogSeverity | |||
| 62 | + | Body string | |||
| 63 | + | // Stream is stdout or stderr for container output, empty otherwise. | |||
| 64 | + | Stream string | |||
| 65 | + | // Attributes carry per-record metadata (status, path...). | |||
| 66 | + | Attributes map[string]string | |||
| 67 | + | } | |||
M internal/usecase/apptraffic/activate.go +9 -0
| @@ -64,6 +64,15 @@ func (h *HostIndex) Lookup(host string) (RouteEntry, bool) { | |||
| 64 | 64 | return entry, ok | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | // HostOwner returns the app and service serving a canonical host. | |
| 68 | + | func (h *HostIndex) HostOwner(host string) (app, service string, ok bool) { | |
| 69 | + | entry, found := h.Lookup(host) | |
| 70 | + | if !found { | |
| 71 | + | return "", "", false | |
| 72 | + | } | |
| 73 | + | return entry.App, entry.Service, true | |
| 74 | + | } | |
| 75 | + | ||
| 67 | 76 | // LookupHost implements proxy.TargetProvider over the host index. | |
| 68 | 77 | // The proxy package owns the interface; apptraffic implements it to | |
| 69 | 78 | // keep app-state interpretation out of the proxy. | |
| @@ -64,6 +64,15 @@ func (h *HostIndex) Lookup(host string) (RouteEntry, bool) { | |||||
| 64 | return entry, ok | 64 | return entry, ok | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | // HostOwner returns the app and service serving a canonical host. | |||
| 68 | + | func (h *HostIndex) HostOwner(host string) (app, service string, ok bool) { | |||
| 69 | + | entry, found := h.Lookup(host) | |||
| 70 | + | if !found { | |||
| 71 | + | return "", "", false | |||
| 72 | + | } | |||
| 73 | + | return entry.App, entry.Service, true | |||
| 74 | + | } | |||
| 75 | + | ||||
| 67 | // LookupHost implements proxy.TargetProvider over the host index. | 76 | // LookupHost implements proxy.TargetProvider over the host index. | ||
| 68 | // The proxy package owns the interface; apptraffic implements it to | 77 | // The proxy package owns the interface; apptraffic implements it to | ||
| 69 | // keep app-state interpretation out of the proxy. | 78 | // keep app-state interpretation out of the proxy. | ||
A internal/usecase/logexport/access.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package logexport | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // HostOwnerResolver maps a canonical HTTP host to the app service that | |
| 15 | + | // serves it. apptraffic.HostIndex implements it. | |
| 16 | + | type HostOwnerResolver interface { | |
| 17 | + | HostOwner(host string) (app, service string, ok bool) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // AccessLogExporter exports proxy access entries under the identity of | |
| 21 | + | // the app service that owns the requested host, so one app's access | |
| 22 | + | // and container logs share one source. Unowned hosts (registry, admin, | |
| 23 | + | // unknown) are exported as Gordon's own records. | |
| 24 | + | type AccessLogExporter struct { | |
| 25 | + | hosts HostOwnerResolver | |
| 26 | + | exporter out.LogExporter | |
| 27 | + | next out.AccessLogWriter | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var _ out.AccessLogWriter = (*AccessLogExporter)(nil) | |
| 31 | + | ||
| 32 | + | // NewAccessLogExporter creates the exporter. next, when non-nil, also | |
| 33 | + | // receives every entry (the local access log sink). | |
| 34 | + | func NewAccessLogExporter(hosts HostOwnerResolver, exporter out.LogExporter, next out.AccessLogWriter) *AccessLogExporter { | |
| 35 | + | return &AccessLogExporter{hosts: hosts, exporter: exporter, next: next} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Write implements out.AccessLogWriter. Export never fails the request; | |
| 39 | + | // only the local sink can report an error. | |
| 40 | + | func (a *AccessLogExporter) Write(entry out.AccessLogEntry) error { | |
| 41 | + | a.exporter.Export(context.Background(), a.record(entry)) | |
| 42 | + | if a.next != nil { | |
| 43 | + | return a.next.Write(entry) | |
| 44 | + | } | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (a *AccessLogExporter) record(entry out.AccessLogEntry) domain.LogRecord { | |
| 49 | + | host := domain.CanonicalHTTPHost(stripPort(entry.Host)) | |
| 50 | + | path := strings.ToValidUTF8(entry.Path, "\uFFFD") | |
| 51 | + | userAgent := strings.ToValidUTF8(entry.UserAgent, "\uFFFD") | |
| 52 | + | var source domain.LogSource | |
| 53 | + | if app, service, ok := a.hosts.HostOwner(host); ok { | |
| 54 | + | source = domain.LogSource{App: app, Service: service} | |
| 55 | + | } | |
| 56 | + | return domain.LogRecord{ | |
| 57 | + | Time: entry.Time, | |
| 58 | + | Source: source, | |
| 59 | + | Type: domain.LogTypeAccess, | |
| 60 | + | Severity: accessSeverity(entry.Status), | |
| 61 | + | Body: fmt.Sprintf("%s %s%s %d", entry.Method, host, path, entry.Status), | |
| 62 | + | Attributes: map[string]string{ | |
| 63 | + | "http.request.method": entry.Method, | |
| 64 | + | "http.response.status_code": strconv.Itoa(entry.Status), | |
| 65 | + | "server.address": host, | |
| 66 | + | "url.path": path, | |
| 67 | + | "client.address": entry.ClientIP, | |
| 68 | + | "user_agent.original": userAgent, | |
| 69 | + | "http.request.header.referer": entry.Referer, | |
| 70 | + | "network.protocol.name": entry.Proto, | |
| 71 | + | "http.response.body.size": strconv.Itoa(entry.BytesSent), | |
| 72 | + | "http.server.duration_ms": strconv.FormatFloat(entry.DurationMS, 'f', 3, 64), | |
| 73 | + | "request.id": entry.RequestID, | |
| 74 | + | }, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func stripPort(hostport string) string { | |
| 79 | + | if host, _, err := net.SplitHostPort(hostport); err == nil { | |
| 80 | + | return host | |
| 81 | + | } | |
| 82 | + | return hostport | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func accessSeverity(status int) domain.LogSeverity { | |
| 86 | + | switch { | |
| 87 | + | case status >= 500: | |
| 88 | + | return domain.LogSeverityError | |
| 89 | + | case status >= 400: | |
| 90 | + | return domain.LogSeverityWarn | |
| 91 | + | default: | |
| 92 | + | return domain.LogSeverityInfo | |
| 93 | + | } | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package logexport | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // HostOwnerResolver maps a canonical HTTP host to the app service that | |||
| 15 | + | // serves it. apptraffic.HostIndex implements it. | |||
| 16 | + | type HostOwnerResolver interface { | |||
| 17 | + | HostOwner(host string) (app, service string, ok bool) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // AccessLogExporter exports proxy access entries under the identity of | |||
| 21 | + | // the app service that owns the requested host, so one app's access | |||
| 22 | + | // and container logs share one source. Unowned hosts (registry, admin, | |||
| 23 | + | // unknown) are exported as Gordon's own records. | |||
| 24 | + | type AccessLogExporter struct { | |||
| 25 | + | hosts HostOwnerResolver | |||
| 26 | + | exporter out.LogExporter | |||
| 27 | + | next out.AccessLogWriter | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var _ out.AccessLogWriter = (*AccessLogExporter)(nil) | |||
| 31 | + | ||||
| 32 | + | // NewAccessLogExporter creates the exporter. next, when non-nil, also | |||
| 33 | + | // receives every entry (the local access log sink). | |||
| 34 | + | func NewAccessLogExporter(hosts HostOwnerResolver, exporter out.LogExporter, next out.AccessLogWriter) *AccessLogExporter { | |||
| 35 | + | return &AccessLogExporter{hosts: hosts, exporter: exporter, next: next} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Write implements out.AccessLogWriter. Export never fails the request; | |||
| 39 | + | // only the local sink can report an error. | |||
| 40 | + | func (a *AccessLogExporter) Write(entry out.AccessLogEntry) error { | |||
| 41 | + | a.exporter.Export(context.Background(), a.record(entry)) | |||
| 42 | + | if a.next != nil { | |||
| 43 | + | return a.next.Write(entry) | |||
| 44 | + | } | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (a *AccessLogExporter) record(entry out.AccessLogEntry) domain.LogRecord { | |||
| 49 | + | host := domain.CanonicalHTTPHost(stripPort(entry.Host)) | |||
| 50 | + | path := strings.ToValidUTF8(entry.Path, "\uFFFD") | |||
| 51 | + | userAgent := strings.ToValidUTF8(entry.UserAgent, "\uFFFD") | |||
| 52 | + | var source domain.LogSource | |||
| 53 | + | if app, service, ok := a.hosts.HostOwner(host); ok { | |||
| 54 | + | source = domain.LogSource{App: app, Service: service} | |||
| 55 | + | } | |||
| 56 | + | return domain.LogRecord{ | |||
| 57 | + | Time: entry.Time, | |||
| 58 | + | Source: source, | |||
| 59 | + | Type: domain.LogTypeAccess, | |||
| 60 | + | Severity: accessSeverity(entry.Status), | |||
| 61 | + | Body: fmt.Sprintf("%s %s%s %d", entry.Method, host, path, entry.Status), | |||
| 62 | + | Attributes: map[string]string{ | |||
| 63 | + | "http.request.method": entry.Method, | |||
| 64 | + | "http.response.status_code": strconv.Itoa(entry.Status), | |||
| 65 | + | "server.address": host, | |||
| 66 | + | "url.path": path, | |||
| 67 | + | "client.address": entry.ClientIP, | |||
| 68 | + | "user_agent.original": userAgent, | |||
| 69 | + | "http.request.header.referer": entry.Referer, | |||
| 70 | + | "network.protocol.name": entry.Proto, | |||
| 71 | + | "http.response.body.size": strconv.Itoa(entry.BytesSent), | |||
| 72 | + | "http.server.duration_ms": strconv.FormatFloat(entry.DurationMS, 'f', 3, 64), | |||
| 73 | + | "request.id": entry.RequestID, | |||
| 74 | + | }, | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func stripPort(hostport string) string { | |||
| 79 | + | if host, _, err := net.SplitHostPort(hostport); err == nil { | |||
| 80 | + | return host | |||
| 81 | + | } | |||
| 82 | + | return hostport | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func accessSeverity(status int) domain.LogSeverity { | |||
| 86 | + | switch { | |||
| 87 | + | case status >= 500: | |||
| 88 | + | return domain.LogSeverityError | |||
| 89 | + | case status >= 400: | |||
| 90 | + | return domain.LogSeverityWarn | |||
| 91 | + | default: | |||
| 92 | + | return domain.LogSeverityInfo | |||
| 93 | + | } | |||
| 94 | + | } | |||
A internal/usecase/logexport/access_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package logexport | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type fakeHosts map[string][2]string | |
| 19 | + | ||
| 20 | + | func (f fakeHosts) HostOwner(host string) (string, string, bool) { | |
| 21 | + | owner, ok := f[host] | |
| 22 | + | return owner[0], owner[1], ok | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type fakeAccessWriter struct { | |
| 26 | + | entries []out.AccessLogEntry | |
| 27 | + | err error | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (f *fakeAccessWriter) Write(entry out.AccessLogEntry) error { | |
| 31 | + | f.entries = append(f.entries, entry) | |
| 32 | + | return f.err | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func captureExport(t *testing.T) (*mocks.MockLogExporter, *[]domain.LogRecord) { | |
| 36 | + | var records []domain.LogRecord | |
| 37 | + | exporter := mocks.NewMockLogExporter(t) | |
| 38 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Run(func(_ context.Context, r domain.LogRecord) { | |
| 39 | + | records = append(records, r) | |
| 40 | + | }).Return() | |
| 41 | + | return exporter, &records | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestAccessLogExporter_AttributesOwnedHostToAppService(t *testing.T) { | |
| 45 | + | exporter, records := captureExport(t) | |
| 46 | + | next := &fakeAccessWriter{} | |
| 47 | + | a := NewAccessLogExporter(fakeHosts{"blog.example.com": {"blog", "web"}}, exporter, next) | |
| 48 | + | ts := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |
| 49 | + | ||
| 50 | + | err := a.Write(out.AccessLogEntry{Time: ts, Method: "GET", Host: "Blog.Example.com:443", Path: "/x", Status: 502}) | |
| 51 | + | ||
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Len(t, *records, 1) | |
| 54 | + | record := (*records)[0] | |
| 55 | + | assert.Equal(t, domain.LogSource{App: "blog", Service: "web"}, record.Source) | |
| 56 | + | assert.Equal(t, domain.LogTypeAccess, record.Type) | |
| 57 | + | assert.Equal(t, domain.LogSeverityError, record.Severity) | |
| 58 | + | assert.Equal(t, ts, record.Time) | |
| 59 | + | assert.Equal(t, "GET blog.example.com/x 502", record.Body) | |
| 60 | + | assert.Equal(t, "502", record.Attributes["http.response.status_code"]) | |
| 61 | + | assert.Len(t, next.entries, 1, "local sink still receives the entry") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestAccessLogExporter_UnownedHostIsGordon(t *testing.T) { | |
| 65 | + | exporter, records := captureExport(t) | |
| 66 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |
| 67 | + | ||
| 68 | + | require.NoError(t, a.Write(out.AccessLogEntry{Host: "registry.example.com", Status: 200})) | |
| 69 | + | ||
| 70 | + | require.Len(t, *records, 1) | |
| 71 | + | assert.True(t, (*records)[0].Source.IsGordon()) | |
| 72 | + | assert.Equal(t, domain.LogSeverityInfo, (*records)[0].Severity) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestAccessLogExporter_ReturnsLocalSinkError(t *testing.T) { | |
| 76 | + | exporter, _ := captureExport(t) | |
| 77 | + | sinkErr := errors.New("disk full") | |
| 78 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, &fakeAccessWriter{err: sinkErr}) | |
| 79 | + | ||
| 80 | + | assert.ErrorIs(t, a.Write(out.AccessLogEntry{Status: 404}), sinkErr) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestAccessLogExporter_DropsQueryAttribute(t *testing.T) { | |
| 84 | + | exporter, records := captureExport(t) | |
| 85 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |
| 86 | + | ||
| 87 | + | require.NoError(t, a.Write(out.AccessLogEntry{Path: "/search", Query: "q=secret", Status: 200})) | |
| 88 | + | ||
| 89 | + | require.Len(t, *records, 1) | |
| 90 | + | _, ok := (*records)[0].Attributes["url.query"] | |
| 91 | + | assert.False(t, ok, "query must not be exported over OTLP") | |
| 92 | + | assert.Equal(t, "/search", (*records)[0].Attributes["url.path"]) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestAccessLogExporter_SanitizesInvalidUTF8(t *testing.T) { | |
| 96 | + | exporter, records := captureExport(t) | |
| 97 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |
| 98 | + | ||
| 99 | + | require.NoError(t, a.Write(out.AccessLogEntry{Path: "/bad\xff", UserAgent: "agent\xff", Status: 200})) | |
| 100 | + | ||
| 101 | + | require.Len(t, *records, 1) | |
| 102 | + | assert.Equal(t, "/bad\uFFFD", (*records)[0].Attributes["url.path"]) | |
| 103 | + | assert.Equal(t, "agent\uFFFD", (*records)[0].Attributes["user_agent.original"]) | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package logexport | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type fakeHosts map[string][2]string | |||
| 19 | + | ||||
| 20 | + | func (f fakeHosts) HostOwner(host string) (string, string, bool) { | |||
| 21 | + | owner, ok := f[host] | |||
| 22 | + | return owner[0], owner[1], ok | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | type fakeAccessWriter struct { | |||
| 26 | + | entries []out.AccessLogEntry | |||
| 27 | + | err error | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (f *fakeAccessWriter) Write(entry out.AccessLogEntry) error { | |||
| 31 | + | f.entries = append(f.entries, entry) | |||
| 32 | + | return f.err | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func captureExport(t *testing.T) (*mocks.MockLogExporter, *[]domain.LogRecord) { | |||
| 36 | + | var records []domain.LogRecord | |||
| 37 | + | exporter := mocks.NewMockLogExporter(t) | |||
| 38 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Run(func(_ context.Context, r domain.LogRecord) { | |||
| 39 | + | records = append(records, r) | |||
| 40 | + | }).Return() | |||
| 41 | + | return exporter, &records | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestAccessLogExporter_AttributesOwnedHostToAppService(t *testing.T) { | |||
| 45 | + | exporter, records := captureExport(t) | |||
| 46 | + | next := &fakeAccessWriter{} | |||
| 47 | + | a := NewAccessLogExporter(fakeHosts{"blog.example.com": {"blog", "web"}}, exporter, next) | |||
| 48 | + | ts := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |||
| 49 | + | ||||
| 50 | + | err := a.Write(out.AccessLogEntry{Time: ts, Method: "GET", Host: "Blog.Example.com:443", Path: "/x", Status: 502}) | |||
| 51 | + | ||||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Len(t, *records, 1) | |||
| 54 | + | record := (*records)[0] | |||
| 55 | + | assert.Equal(t, domain.LogSource{App: "blog", Service: "web"}, record.Source) | |||
| 56 | + | assert.Equal(t, domain.LogTypeAccess, record.Type) | |||
| 57 | + | assert.Equal(t, domain.LogSeverityError, record.Severity) | |||
| 58 | + | assert.Equal(t, ts, record.Time) | |||
| 59 | + | assert.Equal(t, "GET blog.example.com/x 502", record.Body) | |||
| 60 | + | assert.Equal(t, "502", record.Attributes["http.response.status_code"]) | |||
| 61 | + | assert.Len(t, next.entries, 1, "local sink still receives the entry") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestAccessLogExporter_UnownedHostIsGordon(t *testing.T) { | |||
| 65 | + | exporter, records := captureExport(t) | |||
| 66 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |||
| 67 | + | ||||
| 68 | + | require.NoError(t, a.Write(out.AccessLogEntry{Host: "registry.example.com", Status: 200})) | |||
| 69 | + | ||||
| 70 | + | require.Len(t, *records, 1) | |||
| 71 | + | assert.True(t, (*records)[0].Source.IsGordon()) | |||
| 72 | + | assert.Equal(t, domain.LogSeverityInfo, (*records)[0].Severity) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestAccessLogExporter_ReturnsLocalSinkError(t *testing.T) { | |||
| 76 | + | exporter, _ := captureExport(t) | |||
| 77 | + | sinkErr := errors.New("disk full") | |||
| 78 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, &fakeAccessWriter{err: sinkErr}) | |||
| 79 | + | ||||
| 80 | + | assert.ErrorIs(t, a.Write(out.AccessLogEntry{Status: 404}), sinkErr) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestAccessLogExporter_DropsQueryAttribute(t *testing.T) { | |||
| 84 | + | exporter, records := captureExport(t) | |||
| 85 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |||
| 86 | + | ||||
| 87 | + | require.NoError(t, a.Write(out.AccessLogEntry{Path: "/search", Query: "q=secret", Status: 200})) | |||
| 88 | + | ||||
| 89 | + | require.Len(t, *records, 1) | |||
| 90 | + | _, ok := (*records)[0].Attributes["url.query"] | |||
| 91 | + | assert.False(t, ok, "query must not be exported over OTLP") | |||
| 92 | + | assert.Equal(t, "/search", (*records)[0].Attributes["url.path"]) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestAccessLogExporter_SanitizesInvalidUTF8(t *testing.T) { | |||
| 96 | + | exporter, records := captureExport(t) | |||
| 97 | + | a := NewAccessLogExporter(fakeHosts{}, exporter, nil) | |||
| 98 | + | ||||
| 99 | + | require.NoError(t, a.Write(out.AccessLogEntry{Path: "/bad\xff", UserAgent: "agent\xff", Status: 200})) | |||
| 100 | + | ||||
| 101 | + | require.Len(t, *records, 1) | |||
| 102 | + | assert.Equal(t, "/bad\uFFFD", (*records)[0].Attributes["url.path"]) | |||
| 103 | + | assert.Equal(t, "agent\uFFFD", (*records)[0].Attributes["user_agent.original"]) | |||
| 104 | + | } | |||
A internal/usecase/logexport/collector.go +218 -0
| @@ -0,0 +1,218 @@ | |||
| 1 | + | // Package logexport follows app container output and exports it as | |
| 2 | + | // log records, one source identity per app service. | |
| 3 | + | package logexport | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | defaultReconcileInterval = 10 * time.Second | |
| 20 | + | defaultRetryDelay = 2 * time.Second | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Collector keeps one follower per exportable ACTIVE container. | |
| 24 | + | // | |
| 25 | + | // A container is exportable when its app is not stopped and its service | |
| 26 | + | // did not opt out (domain.AppService.LogExportDisabled). Reconciliation | |
| 27 | + | // polls ACTIVE state so deploys, stops, and opt-out changes converge | |
| 28 | + | // without coupling the deployment engine to log export. | |
| 29 | + | type Collector struct { | |
| 30 | + | state out.AppStateReader | |
| 31 | + | streamer out.ContainerLogStreamer | |
| 32 | + | exporter out.LogExporter | |
| 33 | + | ||
| 34 | + | reconcileInterval time.Duration | |
| 35 | + | retryDelay time.Duration | |
| 36 | + | // startedAt bounds replay: a follower exports output emitted since | |
| 37 | + | // the collector was created, so a container deployed later is | |
| 38 | + | // exported from its first line, while restarting Gordon never | |
| 39 | + | // re-exports older output. | |
| 40 | + | startedAt time.Time | |
| 41 | + | ||
| 42 | + | mu sync.Mutex | |
| 43 | + | followers map[string]*follower | |
| 44 | + | cursors map[string]time.Time | |
| 45 | + | wg sync.WaitGroup | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type follower struct { | |
| 49 | + | source domain.LogSource | |
| 50 | + | cancel context.CancelFunc | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // NewCollector creates a collector. Call Run to start it. | |
| 54 | + | func NewCollector(state out.AppStateReader, streamer out.ContainerLogStreamer, exporter out.LogExporter) *Collector { | |
| 55 | + | return &Collector{ | |
| 56 | + | state: state, | |
| 57 | + | streamer: streamer, | |
| 58 | + | exporter: exporter, | |
| 59 | + | reconcileInterval: defaultReconcileInterval, | |
| 60 | + | retryDelay: defaultRetryDelay, | |
| 61 | + | startedAt: time.Now(), | |
| 62 | + | followers: map[string]*follower{}, | |
| 63 | + | cursors: map[string]time.Time{}, | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Run reconciles until ctx is canceled, then stops every follower and | |
| 68 | + | // waits for them to exit. | |
| 69 | + | func (c *Collector) Run(ctx context.Context) { | |
| 70 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 71 | + | zerowrap.FieldLayer: "usecase", | |
| 72 | + | zerowrap.FieldUseCase: "LogExport", | |
| 73 | + | }) | |
| 74 | + | defer c.stopAll() | |
| 75 | + | log := zerowrap.FromCtx(ctx) | |
| 76 | + | ||
| 77 | + | ticker := time.NewTicker(c.reconcileInterval) | |
| 78 | + | defer ticker.Stop() | |
| 79 | + | for { | |
| 80 | + | if err := c.Reconcile(ctx); err != nil && ctx.Err() == nil { | |
| 81 | + | log.Warn().Err(err).Msg("log export reconciliation failed") | |
| 82 | + | } | |
| 83 | + | select { | |
| 84 | + | case <-ctx.Done(): | |
| 85 | + | return | |
| 86 | + | case <-ticker.C: | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Reconcile starts followers for new exportable containers and stops | |
| 92 | + | // followers whose container is gone, stopped, or opted out. | |
| 93 | + | func (c *Collector) Reconcile(ctx context.Context) error { | |
| 94 | + | desired, activeIDs, err := c.desiredContainers(ctx) | |
| 95 | + | if err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | c.mu.Lock() | |
| 100 | + | defer c.mu.Unlock() | |
| 101 | + | for containerID, f := range c.followers { | |
| 102 | + | if source, ok := desired[containerID]; !ok || source != f.source { | |
| 103 | + | f.cancel() | |
| 104 | + | delete(c.followers, containerID) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | for containerID, source := range desired { | |
| 108 | + | if _, ok := c.followers[containerID]; ok { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | c.startLocked(ctx, containerID, source) | |
| 112 | + | } | |
| 113 | + | for containerID := range c.cursors { | |
| 114 | + | if _, ok := activeIDs[containerID]; !ok { | |
| 115 | + | delete(c.cursors, containerID) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // desiredContainers maps exportable ACTIVE container IDs to their source, | |
| 122 | + | // plus the set of every container ID referenced by an ACTIVE service | |
| 123 | + | // (exportable or not) so callers can retire per-container state only when | |
| 124 | + | // the ID is gone from all ACTIVE services. | |
| 125 | + | func (c *Collector) desiredContainers(ctx context.Context) (map[string]domain.LogSource, map[string]struct{}, error) { | |
| 126 | + | apps, err := c.state.ListApps(ctx) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, nil, fmt.Errorf("logexport: list apps: %w", err) | |
| 129 | + | } | |
| 130 | + | desired := map[string]domain.LogSource{} | |
| 131 | + | activeIDs := map[string]struct{}{} | |
| 132 | + | for _, app := range apps { | |
| 133 | + | active, found, err := c.state.LoadActive(ctx, app) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, nil, fmt.Errorf("logexport: load active state for app %q: %w", app, err) | |
| 136 | + | } | |
| 137 | + | if !found || active.StopIntent { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | for name, svc := range active.Services { | |
| 141 | + | if svc.Container == "" { | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | activeIDs[svc.Container] = struct{}{} | |
| 145 | + | if svc.Spec.LogExportDisabled { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | desired[svc.Container] = domain.LogSource{App: app, Service: name} | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | return desired, activeIDs, nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (c *Collector) startLocked(parent context.Context, containerID string, source domain.LogSource) { | |
| 155 | + | ctx, cancel := context.WithCancel(parent) | |
| 156 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 157 | + | zerowrap.FieldEntityID: containerID, | |
| 158 | + | "app": source.App, | |
| 159 | + | "service": source.Service, | |
| 160 | + | }) | |
| 161 | + | c.followers[containerID] = &follower{source: source, cancel: cancel} | |
| 162 | + | since := c.startedAt | |
| 163 | + | if cursor, ok := c.cursors[containerID]; ok && cursor.After(since) { | |
| 164 | + | since = cursor | |
| 165 | + | } | |
| 166 | + | c.wg.Add(1) | |
| 167 | + | go func() { | |
| 168 | + | defer c.wg.Done() | |
| 169 | + | c.follow(ctx, containerID, source, since) | |
| 170 | + | }() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // follow streams one container until canceled, starting at since. | |
| 174 | + | // A stream that ends (container restarting) resumes after the last | |
| 175 | + | // exported line, so a reconnect neither duplicates nor replays output. | |
| 176 | + | func (c *Collector) follow(ctx context.Context, containerID string, source domain.LogSource, since time.Time) { | |
| 177 | + | log := zerowrap.FromCtx(ctx) | |
| 178 | + | for { | |
| 179 | + | err := c.streamer.StreamContainerLogs(ctx, containerID, since, func(line domain.ContainerLogLine) { | |
| 180 | + | c.exporter.Export(ctx, domain.LogRecord{ | |
| 181 | + | Time: line.Time, | |
| 182 | + | Source: source, | |
| 183 | + | Type: domain.LogTypeContainer, | |
| 184 | + | Stream: line.Stream, | |
| 185 | + | Body: line.Body, | |
| 186 | + | }) | |
| 187 | + | if line.Time.After(since) { | |
| 188 | + | since = line.Time.Add(time.Nanosecond) | |
| 189 | + | c.mu.Lock() | |
| 190 | + | if cursor, ok := c.cursors[containerID]; !ok || since.After(cursor) { | |
| 191 | + | c.cursors[containerID] = since | |
| 192 | + | } | |
| 193 | + | c.mu.Unlock() | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | if ctx.Err() != nil { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | if err != nil && !errors.Is(err, domain.ErrContainerNotFound) { | |
| 200 | + | log.Debug().Err(err).Msg("container log stream interrupted, retrying") | |
| 201 | + | } | |
| 202 | + | select { | |
| 203 | + | case <-ctx.Done(): | |
| 204 | + | return | |
| 205 | + | case <-time.After(c.retryDelay): | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (c *Collector) stopAll() { | |
| 211 | + | c.mu.Lock() | |
| 212 | + | for containerID, f := range c.followers { | |
| 213 | + | f.cancel() | |
| 214 | + | delete(c.followers, containerID) | |
| 215 | + | } | |
| 216 | + | c.mu.Unlock() | |
| 217 | + | c.wg.Wait() | |
| 218 | + | } | |
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | // Package logexport follows app container output and exports it as | |||
| 2 | + | // log records, one source identity per app service. | |||
| 3 | + | package logexport | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | defaultReconcileInterval = 10 * time.Second | |||
| 20 | + | defaultRetryDelay = 2 * time.Second | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Collector keeps one follower per exportable ACTIVE container. | |||
| 24 | + | // | |||
| 25 | + | // A container is exportable when its app is not stopped and its service | |||
| 26 | + | // did not opt out (domain.AppService.LogExportDisabled). Reconciliation | |||
| 27 | + | // polls ACTIVE state so deploys, stops, and opt-out changes converge | |||
| 28 | + | // without coupling the deployment engine to log export. | |||
| 29 | + | type Collector struct { | |||
| 30 | + | state out.AppStateReader | |||
| 31 | + | streamer out.ContainerLogStreamer | |||
| 32 | + | exporter out.LogExporter | |||
| 33 | + | ||||
| 34 | + | reconcileInterval time.Duration | |||
| 35 | + | retryDelay time.Duration | |||
| 36 | + | // startedAt bounds replay: a follower exports output emitted since | |||
| 37 | + | // the collector was created, so a container deployed later is | |||
| 38 | + | // exported from its first line, while restarting Gordon never | |||
| 39 | + | // re-exports older output. | |||
| 40 | + | startedAt time.Time | |||
| 41 | + | ||||
| 42 | + | mu sync.Mutex | |||
| 43 | + | followers map[string]*follower | |||
| 44 | + | cursors map[string]time.Time | |||
| 45 | + | wg sync.WaitGroup | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type follower struct { | |||
| 49 | + | source domain.LogSource | |||
| 50 | + | cancel context.CancelFunc | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // NewCollector creates a collector. Call Run to start it. | |||
| 54 | + | func NewCollector(state out.AppStateReader, streamer out.ContainerLogStreamer, exporter out.LogExporter) *Collector { | |||
| 55 | + | return &Collector{ | |||
| 56 | + | state: state, | |||
| 57 | + | streamer: streamer, | |||
| 58 | + | exporter: exporter, | |||
| 59 | + | reconcileInterval: defaultReconcileInterval, | |||
| 60 | + | retryDelay: defaultRetryDelay, | |||
| 61 | + | startedAt: time.Now(), | |||
| 62 | + | followers: map[string]*follower{}, | |||
| 63 | + | cursors: map[string]time.Time{}, | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Run reconciles until ctx is canceled, then stops every follower and | |||
| 68 | + | // waits for them to exit. | |||
| 69 | + | func (c *Collector) Run(ctx context.Context) { | |||
| 70 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 71 | + | zerowrap.FieldLayer: "usecase", | |||
| 72 | + | zerowrap.FieldUseCase: "LogExport", | |||
| 73 | + | }) | |||
| 74 | + | defer c.stopAll() | |||
| 75 | + | log := zerowrap.FromCtx(ctx) | |||
| 76 | + | ||||
| 77 | + | ticker := time.NewTicker(c.reconcileInterval) | |||
| 78 | + | defer ticker.Stop() | |||
| 79 | + | for { | |||
| 80 | + | if err := c.Reconcile(ctx); err != nil && ctx.Err() == nil { | |||
| 81 | + | log.Warn().Err(err).Msg("log export reconciliation failed") | |||
| 82 | + | } | |||
| 83 | + | select { | |||
| 84 | + | case <-ctx.Done(): | |||
| 85 | + | return | |||
| 86 | + | case <-ticker.C: | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Reconcile starts followers for new exportable containers and stops | |||
| 92 | + | // followers whose container is gone, stopped, or opted out. | |||
| 93 | + | func (c *Collector) Reconcile(ctx context.Context) error { | |||
| 94 | + | desired, activeIDs, err := c.desiredContainers(ctx) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | c.mu.Lock() | |||
| 100 | + | defer c.mu.Unlock() | |||
| 101 | + | for containerID, f := range c.followers { | |||
| 102 | + | if source, ok := desired[containerID]; !ok || source != f.source { | |||
| 103 | + | f.cancel() | |||
| 104 | + | delete(c.followers, containerID) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | for containerID, source := range desired { | |||
| 108 | + | if _, ok := c.followers[containerID]; ok { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | c.startLocked(ctx, containerID, source) | |||
| 112 | + | } | |||
| 113 | + | for containerID := range c.cursors { | |||
| 114 | + | if _, ok := activeIDs[containerID]; !ok { | |||
| 115 | + | delete(c.cursors, containerID) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | return nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // desiredContainers maps exportable ACTIVE container IDs to their source, | |||
| 122 | + | // plus the set of every container ID referenced by an ACTIVE service | |||
| 123 | + | // (exportable or not) so callers can retire per-container state only when | |||
| 124 | + | // the ID is gone from all ACTIVE services. | |||
| 125 | + | func (c *Collector) desiredContainers(ctx context.Context) (map[string]domain.LogSource, map[string]struct{}, error) { | |||
| 126 | + | apps, err := c.state.ListApps(ctx) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, nil, fmt.Errorf("logexport: list apps: %w", err) | |||
| 129 | + | } | |||
| 130 | + | desired := map[string]domain.LogSource{} | |||
| 131 | + | activeIDs := map[string]struct{}{} | |||
| 132 | + | for _, app := range apps { | |||
| 133 | + | active, found, err := c.state.LoadActive(ctx, app) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, nil, fmt.Errorf("logexport: load active state for app %q: %w", app, err) | |||
| 136 | + | } | |||
| 137 | + | if !found || active.StopIntent { | |||
| 138 | + | continue | |||
| 139 | + | } | |||
| 140 | + | for name, svc := range active.Services { | |||
| 141 | + | if svc.Container == "" { | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | activeIDs[svc.Container] = struct{}{} | |||
| 145 | + | if svc.Spec.LogExportDisabled { | |||
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | desired[svc.Container] = domain.LogSource{App: app, Service: name} | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | return desired, activeIDs, nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (c *Collector) startLocked(parent context.Context, containerID string, source domain.LogSource) { | |||
| 155 | + | ctx, cancel := context.WithCancel(parent) | |||
| 156 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 157 | + | zerowrap.FieldEntityID: containerID, | |||
| 158 | + | "app": source.App, | |||
| 159 | + | "service": source.Service, | |||
| 160 | + | }) | |||
| 161 | + | c.followers[containerID] = &follower{source: source, cancel: cancel} | |||
| 162 | + | since := c.startedAt | |||
| 163 | + | if cursor, ok := c.cursors[containerID]; ok && cursor.After(since) { | |||
| 164 | + | since = cursor | |||
| 165 | + | } | |||
| 166 | + | c.wg.Add(1) | |||
| 167 | + | go func() { | |||
| 168 | + | defer c.wg.Done() | |||
| 169 | + | c.follow(ctx, containerID, source, since) | |||
| 170 | + | }() | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // follow streams one container until canceled, starting at since. | |||
| 174 | + | // A stream that ends (container restarting) resumes after the last | |||
| 175 | + | // exported line, so a reconnect neither duplicates nor replays output. | |||
| 176 | + | func (c *Collector) follow(ctx context.Context, containerID string, source domain.LogSource, since time.Time) { | |||
| 177 | + | log := zerowrap.FromCtx(ctx) | |||
| 178 | + | for { | |||
| 179 | + | err := c.streamer.StreamContainerLogs(ctx, containerID, since, func(line domain.ContainerLogLine) { | |||
| 180 | + | c.exporter.Export(ctx, domain.LogRecord{ | |||
| 181 | + | Time: line.Time, | |||
| 182 | + | Source: source, | |||
| 183 | + | Type: domain.LogTypeContainer, | |||
| 184 | + | Stream: line.Stream, | |||
| 185 | + | Body: line.Body, | |||
| 186 | + | }) | |||
| 187 | + | if line.Time.After(since) { | |||
| 188 | + | since = line.Time.Add(time.Nanosecond) | |||
| 189 | + | c.mu.Lock() | |||
| 190 | + | if cursor, ok := c.cursors[containerID]; !ok || since.After(cursor) { | |||
| 191 | + | c.cursors[containerID] = since | |||
| 192 | + | } | |||
| 193 | + | c.mu.Unlock() | |||
| 194 | + | } | |||
| 195 | + | }) | |||
| 196 | + | if ctx.Err() != nil { | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | if err != nil && !errors.Is(err, domain.ErrContainerNotFound) { | |||
| 200 | + | log.Debug().Err(err).Msg("container log stream interrupted, retrying") | |||
| 201 | + | } | |||
| 202 | + | select { | |||
| 203 | + | case <-ctx.Done(): | |||
| 204 | + | return | |||
| 205 | + | case <-time.After(c.retryDelay): | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (c *Collector) stopAll() { | |||
| 211 | + | c.mu.Lock() | |||
| 212 | + | for containerID, f := range c.followers { | |||
| 213 | + | f.cancel() | |||
| 214 | + | delete(c.followers, containerID) | |||
| 215 | + | } | |||
| 216 | + | c.mu.Unlock() | |||
| 217 | + | c.wg.Wait() | |||
| 218 | + | } | |||
A internal/usecase/logexport/collector_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | package logexport | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func activeApp(app string, stopped bool, services map[string]domain.AppEffectiveService) domain.AppActive { | |
| 19 | + | return domain.AppActive{App: app, StopIntent: stopped, Services: services} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func svc(container string, disabled bool) domain.AppEffectiveService { | |
| 23 | + | return domain.AppEffectiveService{Container: container, Spec: domain.AppService{LogExportDisabled: disabled}} | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // blockingStreamer blocks each follower until canceled and records calls. | |
| 27 | + | type blockingStreamer struct { | |
| 28 | + | mu sync.Mutex | |
| 29 | + | started map[string]int | |
| 30 | + | stopped map[string]int | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newBlockingStreamer() *blockingStreamer { | |
| 34 | + | return &blockingStreamer{started: map[string]int{}, stopped: map[string]int{}} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (s *blockingStreamer) StreamContainerLogs(ctx context.Context, containerID string, _ time.Time, _ func(domain.ContainerLogLine)) error { | |
| 38 | + | s.mu.Lock() | |
| 39 | + | s.started[containerID]++ | |
| 40 | + | s.mu.Unlock() | |
| 41 | + | <-ctx.Done() | |
| 42 | + | s.mu.Lock() | |
| 43 | + | s.stopped[containerID]++ | |
| 44 | + | s.mu.Unlock() | |
| 45 | + | return ctx.Err() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *blockingStreamer) counts(containerID string) (int, int) { | |
| 49 | + | s.mu.Lock() | |
| 50 | + | defer s.mu.Unlock() | |
| 51 | + | return s.started[containerID], s.stopped[containerID] | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestCollector_FollowsOnlyExportableContainers(t *testing.T) { | |
| 55 | + | state := mocks.NewMockAppStateReader(t) | |
| 56 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog", "paused"}, nil) | |
| 57 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 58 | + | "web": svc("c-web", false), | |
| 59 | + | "db": svc("c-db", true), // service opted out | |
| 60 | + | "new": svc("", false), // no container yet | |
| 61 | + | }), true, nil) | |
| 62 | + | state.EXPECT().LoadActive(mock.Anything, "paused").Return(activeApp("paused", true, map[string]domain.AppEffectiveService{ | |
| 63 | + | "web": svc("c-paused", false), | |
| 64 | + | }), true, nil) | |
| 65 | + | streamer := newBlockingStreamer() | |
| 66 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |
| 67 | + | ||
| 68 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 69 | + | require.NoError(t, c.Reconcile(ctx)) | |
| 70 | + | require.Eventually(t, func() bool { started, _ := streamer.counts("c-web"); return started == 1 }, time.Second, time.Millisecond) | |
| 71 | + | cancel() | |
| 72 | + | c.stopAll() | |
| 73 | + | ||
| 74 | + | for _, id := range []string{"c-db", "c-paused"} { | |
| 75 | + | started, _ := streamer.counts(id) | |
| 76 | + | assert.Zero(t, started, id) | |
| 77 | + | } | |
| 78 | + | _, stopped := streamer.counts("c-web") | |
| 79 | + | assert.Equal(t, 1, stopped, "stopAll waits for followers") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestCollector_StopsFollowerWhenServiceOptsOut(t *testing.T) { | |
| 83 | + | state := mocks.NewMockAppStateReader(t) | |
| 84 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |
| 85 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 86 | + | "web": svc("c-web", false), | |
| 87 | + | }), true, nil).Once() | |
| 88 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 89 | + | "web": svc("c-web", true), | |
| 90 | + | }), true, nil).Once() | |
| 91 | + | streamer := newBlockingStreamer() | |
| 92 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |
| 93 | + | defer c.stopAll() | |
| 94 | + | ||
| 95 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 96 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 97 | + | ||
| 98 | + | require.Eventually(t, func() bool { _, stopped := streamer.counts("c-web"); return stopped == 1 }, time.Second, time.Millisecond) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestCollector_ExportsLinesWithSourceIdentity(t *testing.T) { | |
| 102 | + | state := mocks.NewMockAppStateReader(t) | |
| 103 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |
| 104 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 105 | + | "web": svc("c-web", false), | |
| 106 | + | }), true, nil) | |
| 107 | + | ||
| 108 | + | ts := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |
| 109 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |
| 110 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |
| 111 | + | RunAndReturn(func(ctx context.Context, _ string, _ time.Time, emit func(domain.ContainerLogLine)) error { | |
| 112 | + | emit(domain.ContainerLogLine{Time: ts, Stream: domain.LogStreamStderr, Body: "boom"}) | |
| 113 | + | <-ctx.Done() | |
| 114 | + | return ctx.Err() | |
| 115 | + | }) | |
| 116 | + | ||
| 117 | + | exported := make(chan domain.LogRecord, 1) | |
| 118 | + | exporter := mocks.NewMockLogExporter(t) | |
| 119 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Run(func(_ context.Context, r domain.LogRecord) { | |
| 120 | + | exported <- r | |
| 121 | + | }).Return() | |
| 122 | + | ||
| 123 | + | c := NewCollector(state, streamer, exporter) | |
| 124 | + | defer c.stopAll() | |
| 125 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 126 | + | ||
| 127 | + | select { | |
| 128 | + | case record := <-exported: | |
| 129 | + | assert.Equal(t, domain.LogRecord{ | |
| 130 | + | Time: ts, | |
| 131 | + | Source: domain.LogSource{App: "blog", Service: "web"}, | |
| 132 | + | Type: domain.LogTypeContainer, | |
| 133 | + | Stream: domain.LogStreamStderr, | |
| 134 | + | Body: "boom", | |
| 135 | + | }, record) | |
| 136 | + | assert.Equal(t, "blog.web", record.Source.ServiceName()) | |
| 137 | + | case <-time.After(time.Second): | |
| 138 | + | t.Fatal("no record exported") | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestCollector_ResumesAfterLastLineOnReconnect(t *testing.T) { | |
| 143 | + | state := mocks.NewMockAppStateReader(t) | |
| 144 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |
| 145 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 146 | + | "web": svc("c-web", false), | |
| 147 | + | }), true, nil) | |
| 148 | + | ||
| 149 | + | start := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |
| 150 | + | last := start.Add(time.Minute) | |
| 151 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |
| 152 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", start, mock.Anything). | |
| 153 | + | RunAndReturn(func(_ context.Context, _ string, _ time.Time, emit func(domain.ContainerLogLine)) error { | |
| 154 | + | emit(domain.ContainerLogLine{Time: last, Body: "line"}) | |
| 155 | + | return nil // stream ended: container restarting | |
| 156 | + | }).Once() | |
| 157 | + | resumed := make(chan time.Time, 1) | |
| 158 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |
| 159 | + | RunAndReturn(func(ctx context.Context, _ string, since time.Time, _ func(domain.ContainerLogLine)) error { | |
| 160 | + | resumed <- since | |
| 161 | + | <-ctx.Done() | |
| 162 | + | return ctx.Err() | |
| 163 | + | }).Once() | |
| 164 | + | exporter := mocks.NewMockLogExporter(t) | |
| 165 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Return() | |
| 166 | + | ||
| 167 | + | c := NewCollector(state, streamer, exporter) | |
| 168 | + | c.startedAt = start | |
| 169 | + | c.retryDelay = time.Millisecond | |
| 170 | + | defer c.stopAll() | |
| 171 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 172 | + | ||
| 173 | + | select { | |
| 174 | + | case since := <-resumed: | |
| 175 | + | assert.Equal(t, last.Add(time.Nanosecond), since) | |
| 176 | + | case <-time.After(time.Second): | |
| 177 | + | t.Fatal("follower did not reconnect") | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestCollector_ReconcileWrapsStateErrors(t *testing.T) { | |
| 182 | + | listErr := assert.AnError | |
| 183 | + | state := mocks.NewMockAppStateReader(t) | |
| 184 | + | state.EXPECT().ListApps(mock.Anything).Return(nil, listErr) | |
| 185 | + | c := NewCollector(state, newBlockingStreamer(), mocks.NewMockLogExporter(t)) | |
| 186 | + | defer c.stopAll() | |
| 187 | + | ||
| 188 | + | err := c.Reconcile(context.Background()) | |
| 189 | + | require.Error(t, err) | |
| 190 | + | assert.ErrorIs(t, err, listErr) | |
| 191 | + | ||
| 192 | + | loadErr := assert.AnError | |
| 193 | + | state2 := mocks.NewMockAppStateReader(t) | |
| 194 | + | state2.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |
| 195 | + | state2.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, loadErr) | |
| 196 | + | c2 := NewCollector(state2, newBlockingStreamer(), mocks.NewMockLogExporter(t)) | |
| 197 | + | defer c2.stopAll() | |
| 198 | + | ||
| 199 | + | err = c2.Reconcile(context.Background()) | |
| 200 | + | require.Error(t, err) | |
| 201 | + | assert.ErrorIs(t, err, loadErr) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestCollector_RecreatedFollowResumesFromSavedCursor(t *testing.T) { | |
| 205 | + | state := mocks.NewMockAppStateReader(t) | |
| 206 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |
| 207 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 208 | + | "web": svc("c-web", false), | |
| 209 | + | }), true, nil) | |
| 210 | + | ||
| 211 | + | start := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |
| 212 | + | last := start.Add(time.Minute) | |
| 213 | + | streamed := make(chan time.Time, 4) | |
| 214 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |
| 215 | + | first := make(chan struct{}) | |
| 216 | + | // The first stream emits one line then ends (container restart); | |
| 217 | + | // later streams only record their resume point. | |
| 218 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |
| 219 | + | RunAndReturn(func(_ context.Context, _ string, since time.Time, emit func(domain.ContainerLogLine)) error { | |
| 220 | + | select { | |
| 221 | + | case streamed <- since: | |
| 222 | + | default: | |
| 223 | + | } | |
| 224 | + | select { | |
| 225 | + | case <-first: | |
| 226 | + | return errors.New("boom") | |
| 227 | + | default: | |
| 228 | + | close(first) | |
| 229 | + | emit(domain.ContainerLogLine{Time: last, Body: "line"}) | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | }).Maybe() | |
| 233 | + | exporter := mocks.NewMockLogExporter(t) | |
| 234 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Return().Maybe() | |
| 235 | + | ||
| 236 | + | c := NewCollector(state, streamer, exporter) | |
| 237 | + | c.startedAt = start | |
| 238 | + | c.retryDelay = 50 * time.Millisecond | |
| 239 | + | defer c.stopAll() | |
| 240 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 241 | + | ||
| 242 | + | select { | |
| 243 | + | case since := <-streamed: | |
| 244 | + | assert.Equal(t, start, since, "first follow starts at collector start") | |
| 245 | + | case <-time.After(2 * time.Second): | |
| 246 | + | t.Fatal("first follow did not start") | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // The emitted line must advance the saved cursor past startedAt. | |
| 250 | + | require.Eventually(t, func() bool { | |
| 251 | + | c.mu.Lock() | |
| 252 | + | defer c.mu.Unlock() | |
| 253 | + | cursor, ok := c.cursors["c-web"] | |
| 254 | + | return ok && cursor.Equal(last.Add(time.Nanosecond)) | |
| 255 | + | }, 2*time.Second, 10*time.Millisecond, "cursor must advance past the exported line") | |
| 256 | + | ||
| 257 | + | // Simulate the supervision loop recreating the follow (source | |
| 258 | + | // reassignment): the new follow must resume from the saved cursor. | |
| 259 | + | c.mu.Lock() | |
| 260 | + | if f, ok := c.followers["c-web"]; ok { | |
| 261 | + | f.cancel() | |
| 262 | + | delete(c.followers, "c-web") | |
| 263 | + | } | |
| 264 | + | c.mu.Unlock() | |
| 265 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 266 | + | ||
| 267 | + | select { | |
| 268 | + | case since := <-streamed: | |
| 269 | + | assert.Equal(t, last.Add(time.Nanosecond), since, "recreated follow resumes from the saved cursor") | |
| 270 | + | case <-time.After(2 * time.Second): | |
| 271 | + | t.Fatal("recreated follow did not start") | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestCollector_RetiresCursorWhenIDLeavesActive(t *testing.T) { | |
| 276 | + | state := mocks.NewMockAppStateReader(t) | |
| 277 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Times(2) | |
| 278 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 279 | + | "web": svc("c-web", false), | |
| 280 | + | }), true, nil).Once() | |
| 281 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |
| 282 | + | "web": svc("c-new", false), | |
| 283 | + | }), true, nil).Once() | |
| 284 | + | streamer := newBlockingStreamer() | |
| 285 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |
| 286 | + | defer c.stopAll() | |
| 287 | + | ||
| 288 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 289 | + | c.mu.Lock() | |
| 290 | + | c.cursors["c-web"] = time.Now() | |
| 291 | + | c.mu.Unlock() | |
| 292 | + | require.NoError(t, c.Reconcile(context.Background())) | |
| 293 | + | ||
| 294 | + | c.mu.Lock() | |
| 295 | + | _, ok := c.cursors["c-web"] | |
| 296 | + | c.mu.Unlock() | |
| 297 | + | assert.False(t, ok, "cursor is dropped once the ID is gone from ACTIVE services") | |
| 298 | + | } | |
| @@ -0,0 +1,298 @@ | |||||
| 1 | + | package logexport | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func activeApp(app string, stopped bool, services map[string]domain.AppEffectiveService) domain.AppActive { | |||
| 19 | + | return domain.AppActive{App: app, StopIntent: stopped, Services: services} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func svc(container string, disabled bool) domain.AppEffectiveService { | |||
| 23 | + | return domain.AppEffectiveService{Container: container, Spec: domain.AppService{LogExportDisabled: disabled}} | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // blockingStreamer blocks each follower until canceled and records calls. | |||
| 27 | + | type blockingStreamer struct { | |||
| 28 | + | mu sync.Mutex | |||
| 29 | + | started map[string]int | |||
| 30 | + | stopped map[string]int | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newBlockingStreamer() *blockingStreamer { | |||
| 34 | + | return &blockingStreamer{started: map[string]int{}, stopped: map[string]int{}} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (s *blockingStreamer) StreamContainerLogs(ctx context.Context, containerID string, _ time.Time, _ func(domain.ContainerLogLine)) error { | |||
| 38 | + | s.mu.Lock() | |||
| 39 | + | s.started[containerID]++ | |||
| 40 | + | s.mu.Unlock() | |||
| 41 | + | <-ctx.Done() | |||
| 42 | + | s.mu.Lock() | |||
| 43 | + | s.stopped[containerID]++ | |||
| 44 | + | s.mu.Unlock() | |||
| 45 | + | return ctx.Err() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (s *blockingStreamer) counts(containerID string) (int, int) { | |||
| 49 | + | s.mu.Lock() | |||
| 50 | + | defer s.mu.Unlock() | |||
| 51 | + | return s.started[containerID], s.stopped[containerID] | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestCollector_FollowsOnlyExportableContainers(t *testing.T) { | |||
| 55 | + | state := mocks.NewMockAppStateReader(t) | |||
| 56 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog", "paused"}, nil) | |||
| 57 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 58 | + | "web": svc("c-web", false), | |||
| 59 | + | "db": svc("c-db", true), // service opted out | |||
| 60 | + | "new": svc("", false), // no container yet | |||
| 61 | + | }), true, nil) | |||
| 62 | + | state.EXPECT().LoadActive(mock.Anything, "paused").Return(activeApp("paused", true, map[string]domain.AppEffectiveService{ | |||
| 63 | + | "web": svc("c-paused", false), | |||
| 64 | + | }), true, nil) | |||
| 65 | + | streamer := newBlockingStreamer() | |||
| 66 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |||
| 67 | + | ||||
| 68 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 69 | + | require.NoError(t, c.Reconcile(ctx)) | |||
| 70 | + | require.Eventually(t, func() bool { started, _ := streamer.counts("c-web"); return started == 1 }, time.Second, time.Millisecond) | |||
| 71 | + | cancel() | |||
| 72 | + | c.stopAll() | |||
| 73 | + | ||||
| 74 | + | for _, id := range []string{"c-db", "c-paused"} { | |||
| 75 | + | started, _ := streamer.counts(id) | |||
| 76 | + | assert.Zero(t, started, id) | |||
| 77 | + | } | |||
| 78 | + | _, stopped := streamer.counts("c-web") | |||
| 79 | + | assert.Equal(t, 1, stopped, "stopAll waits for followers") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestCollector_StopsFollowerWhenServiceOptsOut(t *testing.T) { | |||
| 83 | + | state := mocks.NewMockAppStateReader(t) | |||
| 84 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |||
| 85 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 86 | + | "web": svc("c-web", false), | |||
| 87 | + | }), true, nil).Once() | |||
| 88 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 89 | + | "web": svc("c-web", true), | |||
| 90 | + | }), true, nil).Once() | |||
| 91 | + | streamer := newBlockingStreamer() | |||
| 92 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |||
| 93 | + | defer c.stopAll() | |||
| 94 | + | ||||
| 95 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 96 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 97 | + | ||||
| 98 | + | require.Eventually(t, func() bool { _, stopped := streamer.counts("c-web"); return stopped == 1 }, time.Second, time.Millisecond) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestCollector_ExportsLinesWithSourceIdentity(t *testing.T) { | |||
| 102 | + | state := mocks.NewMockAppStateReader(t) | |||
| 103 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |||
| 104 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 105 | + | "web": svc("c-web", false), | |||
| 106 | + | }), true, nil) | |||
| 107 | + | ||||
| 108 | + | ts := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |||
| 109 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |||
| 110 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |||
| 111 | + | RunAndReturn(func(ctx context.Context, _ string, _ time.Time, emit func(domain.ContainerLogLine)) error { | |||
| 112 | + | emit(domain.ContainerLogLine{Time: ts, Stream: domain.LogStreamStderr, Body: "boom"}) | |||
| 113 | + | <-ctx.Done() | |||
| 114 | + | return ctx.Err() | |||
| 115 | + | }) | |||
| 116 | + | ||||
| 117 | + | exported := make(chan domain.LogRecord, 1) | |||
| 118 | + | exporter := mocks.NewMockLogExporter(t) | |||
| 119 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Run(func(_ context.Context, r domain.LogRecord) { | |||
| 120 | + | exported <- r | |||
| 121 | + | }).Return() | |||
| 122 | + | ||||
| 123 | + | c := NewCollector(state, streamer, exporter) | |||
| 124 | + | defer c.stopAll() | |||
| 125 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 126 | + | ||||
| 127 | + | select { | |||
| 128 | + | case record := <-exported: | |||
| 129 | + | assert.Equal(t, domain.LogRecord{ | |||
| 130 | + | Time: ts, | |||
| 131 | + | Source: domain.LogSource{App: "blog", Service: "web"}, | |||
| 132 | + | Type: domain.LogTypeContainer, | |||
| 133 | + | Stream: domain.LogStreamStderr, | |||
| 134 | + | Body: "boom", | |||
| 135 | + | }, record) | |||
| 136 | + | assert.Equal(t, "blog.web", record.Source.ServiceName()) | |||
| 137 | + | case <-time.After(time.Second): | |||
| 138 | + | t.Fatal("no record exported") | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestCollector_ResumesAfterLastLineOnReconnect(t *testing.T) { | |||
| 143 | + | state := mocks.NewMockAppStateReader(t) | |||
| 144 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |||
| 145 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 146 | + | "web": svc("c-web", false), | |||
| 147 | + | }), true, nil) | |||
| 148 | + | ||||
| 149 | + | start := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |||
| 150 | + | last := start.Add(time.Minute) | |||
| 151 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |||
| 152 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", start, mock.Anything). | |||
| 153 | + | RunAndReturn(func(_ context.Context, _ string, _ time.Time, emit func(domain.ContainerLogLine)) error { | |||
| 154 | + | emit(domain.ContainerLogLine{Time: last, Body: "line"}) | |||
| 155 | + | return nil // stream ended: container restarting | |||
| 156 | + | }).Once() | |||
| 157 | + | resumed := make(chan time.Time, 1) | |||
| 158 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |||
| 159 | + | RunAndReturn(func(ctx context.Context, _ string, since time.Time, _ func(domain.ContainerLogLine)) error { | |||
| 160 | + | resumed <- since | |||
| 161 | + | <-ctx.Done() | |||
| 162 | + | return ctx.Err() | |||
| 163 | + | }).Once() | |||
| 164 | + | exporter := mocks.NewMockLogExporter(t) | |||
| 165 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Return() | |||
| 166 | + | ||||
| 167 | + | c := NewCollector(state, streamer, exporter) | |||
| 168 | + | c.startedAt = start | |||
| 169 | + | c.retryDelay = time.Millisecond | |||
| 170 | + | defer c.stopAll() | |||
| 171 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 172 | + | ||||
| 173 | + | select { | |||
| 174 | + | case since := <-resumed: | |||
| 175 | + | assert.Equal(t, last.Add(time.Nanosecond), since) | |||
| 176 | + | case <-time.After(time.Second): | |||
| 177 | + | t.Fatal("follower did not reconnect") | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestCollector_ReconcileWrapsStateErrors(t *testing.T) { | |||
| 182 | + | listErr := assert.AnError | |||
| 183 | + | state := mocks.NewMockAppStateReader(t) | |||
| 184 | + | state.EXPECT().ListApps(mock.Anything).Return(nil, listErr) | |||
| 185 | + | c := NewCollector(state, newBlockingStreamer(), mocks.NewMockLogExporter(t)) | |||
| 186 | + | defer c.stopAll() | |||
| 187 | + | ||||
| 188 | + | err := c.Reconcile(context.Background()) | |||
| 189 | + | require.Error(t, err) | |||
| 190 | + | assert.ErrorIs(t, err, listErr) | |||
| 191 | + | ||||
| 192 | + | loadErr := assert.AnError | |||
| 193 | + | state2 := mocks.NewMockAppStateReader(t) | |||
| 194 | + | state2.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |||
| 195 | + | state2.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, loadErr) | |||
| 196 | + | c2 := NewCollector(state2, newBlockingStreamer(), mocks.NewMockLogExporter(t)) | |||
| 197 | + | defer c2.stopAll() | |||
| 198 | + | ||||
| 199 | + | err = c2.Reconcile(context.Background()) | |||
| 200 | + | require.Error(t, err) | |||
| 201 | + | assert.ErrorIs(t, err, loadErr) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestCollector_RecreatedFollowResumesFromSavedCursor(t *testing.T) { | |||
| 205 | + | state := mocks.NewMockAppStateReader(t) | |||
| 206 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil) | |||
| 207 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 208 | + | "web": svc("c-web", false), | |||
| 209 | + | }), true, nil) | |||
| 210 | + | ||||
| 211 | + | start := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) | |||
| 212 | + | last := start.Add(time.Minute) | |||
| 213 | + | streamed := make(chan time.Time, 4) | |||
| 214 | + | streamer := mocks.NewMockContainerLogStreamer(t) | |||
| 215 | + | first := make(chan struct{}) | |||
| 216 | + | // The first stream emits one line then ends (container restart); | |||
| 217 | + | // later streams only record their resume point. | |||
| 218 | + | streamer.EXPECT().StreamContainerLogs(mock.Anything, "c-web", mock.Anything, mock.Anything). | |||
| 219 | + | RunAndReturn(func(_ context.Context, _ string, since time.Time, emit func(domain.ContainerLogLine)) error { | |||
| 220 | + | select { | |||
| 221 | + | case streamed <- since: | |||
| 222 | + | default: | |||
| 223 | + | } | |||
| 224 | + | select { | |||
| 225 | + | case <-first: | |||
| 226 | + | return errors.New("boom") | |||
| 227 | + | default: | |||
| 228 | + | close(first) | |||
| 229 | + | emit(domain.ContainerLogLine{Time: last, Body: "line"}) | |||
| 230 | + | return nil | |||
| 231 | + | } | |||
| 232 | + | }).Maybe() | |||
| 233 | + | exporter := mocks.NewMockLogExporter(t) | |||
| 234 | + | exporter.EXPECT().Export(mock.Anything, mock.Anything).Return().Maybe() | |||
| 235 | + | ||||
| 236 | + | c := NewCollector(state, streamer, exporter) | |||
| 237 | + | c.startedAt = start | |||
| 238 | + | c.retryDelay = 50 * time.Millisecond | |||
| 239 | + | defer c.stopAll() | |||
| 240 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 241 | + | ||||
| 242 | + | select { | |||
| 243 | + | case since := <-streamed: | |||
| 244 | + | assert.Equal(t, start, since, "first follow starts at collector start") | |||
| 245 | + | case <-time.After(2 * time.Second): | |||
| 246 | + | t.Fatal("first follow did not start") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // The emitted line must advance the saved cursor past startedAt. | |||
| 250 | + | require.Eventually(t, func() bool { | |||
| 251 | + | c.mu.Lock() | |||
| 252 | + | defer c.mu.Unlock() | |||
| 253 | + | cursor, ok := c.cursors["c-web"] | |||
| 254 | + | return ok && cursor.Equal(last.Add(time.Nanosecond)) | |||
| 255 | + | }, 2*time.Second, 10*time.Millisecond, "cursor must advance past the exported line") | |||
| 256 | + | ||||
| 257 | + | // Simulate the supervision loop recreating the follow (source | |||
| 258 | + | // reassignment): the new follow must resume from the saved cursor. | |||
| 259 | + | c.mu.Lock() | |||
| 260 | + | if f, ok := c.followers["c-web"]; ok { | |||
| 261 | + | f.cancel() | |||
| 262 | + | delete(c.followers, "c-web") | |||
| 263 | + | } | |||
| 264 | + | c.mu.Unlock() | |||
| 265 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 266 | + | ||||
| 267 | + | select { | |||
| 268 | + | case since := <-streamed: | |||
| 269 | + | assert.Equal(t, last.Add(time.Nanosecond), since, "recreated follow resumes from the saved cursor") | |||
| 270 | + | case <-time.After(2 * time.Second): | |||
| 271 | + | t.Fatal("recreated follow did not start") | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestCollector_RetiresCursorWhenIDLeavesActive(t *testing.T) { | |||
| 276 | + | state := mocks.NewMockAppStateReader(t) | |||
| 277 | + | state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Times(2) | |||
| 278 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 279 | + | "web": svc("c-web", false), | |||
| 280 | + | }), true, nil).Once() | |||
| 281 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(activeApp("blog", false, map[string]domain.AppEffectiveService{ | |||
| 282 | + | "web": svc("c-new", false), | |||
| 283 | + | }), true, nil).Once() | |||
| 284 | + | streamer := newBlockingStreamer() | |||
| 285 | + | c := NewCollector(state, streamer, mocks.NewMockLogExporter(t)) | |||
| 286 | + | defer c.stopAll() | |||
| 287 | + | ||||
| 288 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 289 | + | c.mu.Lock() | |||
| 290 | + | c.cursors["c-web"] = time.Now() | |||
| 291 | + | c.mu.Unlock() | |||
| 292 | + | require.NoError(t, c.Reconcile(context.Background())) | |||
| 293 | + | ||||
| 294 | + | c.mu.Lock() | |||
| 295 | + | _, ok := c.cursors["c-web"] | |||
| 296 | + | c.mu.Unlock() | |||
| 297 | + | assert.False(t, ok, "cursor is dropped once the ID is gone from ACTIVE services") | |||
| 298 | + | } | |||
M internal/usecase/registry/service.go +3 -10
| @@ -15,10 +15,8 @@ import ( | |||
| 15 | 15 | "github.com/bnema/zerowrap" | |
| 16 | 16 | "go.opentelemetry.io/otel" | |
| 17 | 17 | "go.opentelemetry.io/otel/attribute" | |
| 18 | - | "go.opentelemetry.io/otel/metric" | |
| 19 | 18 | "go.opentelemetry.io/otel/trace" | |
| 20 | 19 | ||
| 21 | - | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 22 | 20 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 23 | 21 | "github.com/bnema/gordon/internal/domain" | |
| 24 | 22 | "github.com/bnema/gordon/internal/usecase/registrystate" | |
| @@ -32,13 +30,13 @@ type Service struct { | |||
| 32 | 30 | blobStorage out.BlobStorage | |
| 33 | 31 | manifestStorage out.ManifestStorage | |
| 34 | 32 | eventBus out.EventPublisher | |
| 35 | - | metrics *telemetry.Metrics | |
| 33 | + | metrics out.Metrics | |
| 36 | 34 | mutationMu *sync.RWMutex | |
| 37 | 35 | registryState *registrystate.State | |
| 38 | 36 | } | |
| 39 | 37 | ||
| 40 | 38 | // SetMetrics sets the telemetry metrics for the registry service. | |
| 41 | - | func (s *Service) SetMetrics(m *telemetry.Metrics) { | |
| 39 | + | func (s *Service) SetMetrics(m out.Metrics) { | |
| 42 | 40 | s.metrics = m | |
| 43 | 41 | } | |
| 44 | 42 | ||
| @@ -132,12 +130,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 132 | 130 | ||
| 133 | 131 | // Record push metrics | |
| 134 | 132 | if s.metrics != nil { | |
| 135 | - | attrs := metric.WithAttributes( | |
| 136 | - | attribute.String("name", manifest.Name), | |
| 137 | - | attribute.String("reference", manifest.Reference), | |
| 138 | - | ) | |
| 139 | - | s.metrics.ImagePushTotal.Add(ctx, 1, attrs) | |
| 140 | - | s.metrics.ImagePushSize.Add(ctx, int64(len(manifest.Data)), attrs) | |
| 133 | + | s.metrics.RecordImagePush(ctx, manifest.Name, manifest.Reference, int64(len(manifest.Data))) | |
| 141 | 134 | } | |
| 142 | 135 | ||
| 143 | 136 | // Publish image pushed event only for tag references (not digests). | |
| @@ -15,10 +15,8 @@ import ( | |||||
| 15 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | "go.opentelemetry.io/otel" | 16 | "go.opentelemetry.io/otel" | ||
| 17 | "go.opentelemetry.io/otel/attribute" | 17 | "go.opentelemetry.io/otel/attribute" | ||
| 18 | - | "go.opentelemetry.io/otel/metric" | |||
| 19 | "go.opentelemetry.io/otel/trace" | 18 | "go.opentelemetry.io/otel/trace" | ||
| 20 | 19 | ||||
| 21 | - | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |||
| 22 | "github.com/bnema/gordon/internal/boundaries/out" | 20 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 23 | "github.com/bnema/gordon/internal/domain" | 21 | "github.com/bnema/gordon/internal/domain" | ||
| 24 | "github.com/bnema/gordon/internal/usecase/registrystate" | 22 | "github.com/bnema/gordon/internal/usecase/registrystate" | ||
| @@ -32,13 +30,13 @@ type Service struct { | |||||
| 32 | blobStorage out.BlobStorage | 30 | blobStorage out.BlobStorage | ||
| 33 | manifestStorage out.ManifestStorage | 31 | manifestStorage out.ManifestStorage | ||
| 34 | eventBus out.EventPublisher | 32 | eventBus out.EventPublisher | ||
| 35 | - | metrics *telemetry.Metrics | 33 | + | metrics out.Metrics |
| 36 | mutationMu *sync.RWMutex | 34 | mutationMu *sync.RWMutex | ||
| 37 | registryState *registrystate.State | 35 | registryState *registrystate.State | ||
| 38 | } | 36 | } | ||
| 39 | 37 | ||||
| 40 | // SetMetrics sets the telemetry metrics for the registry service. | 38 | // SetMetrics sets the telemetry metrics for the registry service. | ||
| 41 | - | func (s *Service) SetMetrics(m *telemetry.Metrics) { | 39 | + | func (s *Service) SetMetrics(m out.Metrics) { |
| 42 | s.metrics = m | 40 | s.metrics = m | ||
| 43 | } | 41 | } | ||
| 44 | 42 | ||||
| @@ -132,12 +130,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 132 | 130 | ||||
| 133 | // Record push metrics | 131 | // Record push metrics | ||
| 134 | if s.metrics != nil { | 132 | if s.metrics != nil { | ||
| 135 | - | attrs := metric.WithAttributes( | 133 | + | s.metrics.RecordImagePush(ctx, manifest.Name, manifest.Reference, int64(len(manifest.Data))) |
| 136 | - | attribute.String("name", manifest.Name), | |||
| 137 | - | attribute.String("reference", manifest.Reference), | |||
| 138 | - | ) | |||
| 139 | - | s.metrics.ImagePushTotal.Add(ctx, 1, attrs) | |||
| 140 | - | s.metrics.ImagePushSize.Add(ctx, int64(len(manifest.Data)), attrs) | |||
| 141 | } | 134 | } | ||
| 142 | 135 | ||||
| 143 | // Publish image pushed event only for tag references (not digests). | 136 | // Publish image pushed event only for tag references (not digests). | ||
M internal/usecase/registry/service_test.go +20 -0
| @@ -88,6 +88,26 @@ func TestService_PutManifest_Success(t *testing.T) { | |||
| 88 | 88 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func TestService_PutManifest_RecordsPushMetrics(t *testing.T) { | |
| 92 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 93 | + | metrics := mocks.NewMockMetrics(t) | |
| 94 | + | svc := NewService(mocks.NewMockBlobStorage(t), manifestStorage, nil) | |
| 95 | + | svc.SetMetrics(metrics) | |
| 96 | + | ||
| 97 | + | data := []byte(`{"schemaVersion": 2}`) | |
| 98 | + | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.oci.image.manifest.v1+json", data).Return(nil) | |
| 99 | + | metrics.EXPECT().RecordImagePush(mock.Anything, "myapp", "latest", int64(len(data))).Return() | |
| 100 | + | ||
| 101 | + | _, err := svc.PutManifest(testContext(), &domain.Manifest{ | |
| 102 | + | Name: "myapp", | |
| 103 | + | Reference: "latest", | |
| 104 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |
| 105 | + | Data: data, | |
| 106 | + | }) | |
| 107 | + | ||
| 108 | + | require.NoError(t, err) | |
| 109 | + | } | |
| 110 | + | ||
| 91 | 111 | func TestService_PutManifest_RejectsUnownedBlob(t *testing.T) { | |
| 92 | 112 | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | |
| 93 | 113 | blobStorage := mocks.NewMockBlobStorage(t) | |
| @@ -88,6 +88,26 @@ func TestService_PutManifest_Success(t *testing.T) { | |||||
| 88 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | 88 | assert.True(t, strings.HasPrefix(digest, "sha256:")) | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | + | func TestService_PutManifest_RecordsPushMetrics(t *testing.T) { | |||
| 92 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 93 | + | metrics := mocks.NewMockMetrics(t) | |||
| 94 | + | svc := NewService(mocks.NewMockBlobStorage(t), manifestStorage, nil) | |||
| 95 | + | svc.SetMetrics(metrics) | |||
| 96 | + | ||||
| 97 | + | data := []byte(`{"schemaVersion": 2}`) | |||
| 98 | + | manifestStorage.EXPECT().PutManifest("myapp", "latest", "application/vnd.oci.image.manifest.v1+json", data).Return(nil) | |||
| 99 | + | metrics.EXPECT().RecordImagePush(mock.Anything, "myapp", "latest", int64(len(data))).Return() | |||
| 100 | + | ||||
| 101 | + | _, err := svc.PutManifest(testContext(), &domain.Manifest{ | |||
| 102 | + | Name: "myapp", | |||
| 103 | + | Reference: "latest", | |||
| 104 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |||
| 105 | + | Data: data, | |||
| 106 | + | }) | |||
| 107 | + | ||||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 91 | func TestService_PutManifest_RejectsUnownedBlob(t *testing.T) { | 111 | func TestService_PutManifest_RejectsUnownedBlob(t *testing.T) { | ||
| 92 | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | 112 | const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" | ||
| 93 | blobStorage := mocks.NewMockBlobStorage(t) | 113 | blobStorage := mocks.NewMockBlobStorage(t) | ||