detached 9940049

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 UnverifiedB5690EEEBB952194 The key is unknown
29 files · +1989 -31
M .mockery.yaml +3 -0
@@ -19,6 +19,9 @@ packages:
1919 SecretProvider:
2020 EnvLoader:
2121 ContainerLogWriter:
22+ Metrics:
23+ LogExporter:
24+ ContainerLogStreamer:
2225 TokenStore:
2326 AppState:
2427 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
163163
164164 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).
165165
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+
166180 ## Strictness
167181
168182 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 ## Strictness180 ## 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
8686 auth_token = "" # Base64 user:password for Basic auth
8787 traces = true # Export traces
8888 metrics = true # Export metrics
89-logs = true # Bridge zerolog to OTLP logs
89+logs = true # Export Gordon, access, and app logs
9090 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
9191
9292 # 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 auth86 auth_token = "" # Base64 user:password for Basic auth
87 traces = true # Export traces87 traces = true # Export traces
88 metrics = true # Export metrics88 metrics = true # Export metrics
89-logs = true # Bridge zerolog to OTLP logs89+logs = true # Export Gordon, access, and app logs
90 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all90 trace_sample_rate = 1.0 # 0.0 = none, 1.0 = all
91 91
92 # Installation secret store location92 # Installation secret store location
M docs/config/reference.md +2 -2
@@ -107,7 +107,7 @@ endpoint = "" # OTLP HTTP endpoint URL
107107 auth_token = "" # Base64-encoded user:password for Basic auth
108108 traces = true # Export distributed traces
109109 metrics = true # Export metrics
110-logs = true # Bridge zerolog output to OTLP logs
110+logs = true # Export Gordon, access, and app logs
111111 trace_sample_rate = 1.0 # Fraction of traces to sample (0.0–1.0)
112112
113113 # =============================================================================
@@ -288,7 +288,7 @@ keep_last = 3 # Keep N newest tags per repository
288288 | `telemetry.auth_token` | `""` | Base64 `user:password` for Basic auth |
289289 | `telemetry.traces` | `true` | Export distributed traces |
290290 | `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 |
292292 | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |
293293 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
294294 | `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 auth107 auth_token = "" # Base64-encoded user:password for Basic auth
108 traces = true # Export distributed traces108 traces = true # Export distributed traces
109 metrics = true # Export metrics109 metrics = true # Export metrics
110-logs = true # Bridge zerolog output to OTLP logs110+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
2626 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
2727 | `traces` | bool | `true` | Export distributed traces |
2828 | `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 |
3030 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
3131
3232 ## How It Works
@@ -35,10 +35,59 @@ Gordon initializes an OTel provider at startup. When telemetry is enabled:
3535
3636 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.
3737 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)).
3939
4040 When telemetry is disabled (the default), all OTel instruments are noop -- zero overhead.
4141
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+
4291 ## Endpoint URL
4392
4493 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 Works32 ## 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 URL91 ## 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 (
2323 // rawManifest mirrors the frozen TOML schema for strict decoding.
2424 // Services is keyed by service name: [services.<name>].
2525 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"`
3038 }
3139
3240 // rawNetwork mirrors [network] and its [[network.shared]] children.
@@ -52,6 +60,7 @@ type rawService struct {
5260 Devices []string `toml:"devices"`
5361 Databases []rawDatabase `toml:"database"`
5462 Backup rawBackup `toml:"backup"`
63+ Telemetry rawTelemetry `toml:"telemetry"`
5564 }
5665
5766 // rawReadiness mirrors [services.<name>.readiness].
@@ -221,6 +230,7 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
221230 if err != nil {
222231 return domain.AppSpec{}, err
223232 }
233+ svc.LogExportDisabled = !resolveLogExport(raw.Telemetry.Logs, raw.Services[name].Telemetry.Logs)
224234 spec.Services = append(spec.Services, svc)
225235 }
226236 for _, net := range raw.Network.Shared {
@@ -233,6 +243,17 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
233243 return spec, nil
234244 }
235245
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+
236257 func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
237258 visibility := h.Visibility
238259 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{}, err231 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, nil243 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.Visibility258 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"
571571 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
572572 require.Error(t, err)
573573 }
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 (
99
1010 "github.com/bnema/zerowrap"
1111 "github.com/google/uuid"
12- "go.opentelemetry.io/otel/attribute"
13- "go.opentelemetry.io/otel/metric"
1412
15- "github.com/bnema/gordon/internal/adapters/out/telemetry"
1613 "github.com/bnema/gordon/internal/boundaries/out"
1714 "github.com/bnema/gordon/internal/domain"
1815 )
@@ -27,12 +24,12 @@ type InMemory struct {
2724 cancel context.CancelFunc
2825 bufferSize int
2926 log zerowrap.Logger
30- metrics *telemetry.Metrics
27+ metrics out.Metrics
3128 }
3229
3330 // SetMetrics sets the telemetry metrics for the event bus.
3431 // 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) {
3633 bus.mu.Lock()
3734 bus.metrics = m
3835 bus.mu.Unlock()
@@ -98,9 +95,7 @@ func (bus *InMemory) Publish(eventType domain.EventType, payload any) error {
9895
9996 // Record dropped event metric
10097 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)
10499 }
105100 return fmt.Errorf("event channel is full, dropping event %s", event.ID)
106101 }
@@ -239,9 +234,7 @@ func (bus *InMemory) handleEvent(event domain.Event) {
239234
240235 // Record processed event metric
241236 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)
245238 }
246239 }
247240 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.CancelFunc24 cancel context.CancelFunc
28 bufferSize int25 bufferSize int
29 log zerowrap.Logger26 log zerowrap.Logger
30- metrics *telemetry.Metrics27+ 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 = m34 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 metric96 // 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 metric235 // 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 @@
11 package telemetry
22
33 import (
4+ "context"
5+
46 "go.opentelemetry.io/otel"
7+ "go.opentelemetry.io/otel/attribute"
58 "go.opentelemetry.io/otel/metric"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
612 )
713
14+var _ out.Metrics = (*Metrics)(nil)
15+
816 // Metrics holds Gordon-specific OTel metrics instruments.
917 type Metrics struct {
1018 // Deployments
@@ -80,3 +88,23 @@ func NewMetrics() (*Metrics, error) {
8088
8189 return m, nil
8290 }
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 telemetry1 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 // Deployments18 // Deployments
@@ -80,3 +88,23 @@ func NewMetrics() (*Metrics, error) {
80 88
81 return m, nil89 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 {
2626 AuthToken string `mapstructure:"auth_token"` // Basic auth token (base64 encoded user:pass)
2727 Traces bool `mapstructure:"traces"` // Enable trace export
2828 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
3030 TraceSampleRate float64 `mapstructure:"trace_sample_rate"` // 0=disabled, (0,1)=ratio-based, >=1=always sample
3131 }
3232
@@ -35,6 +35,9 @@ type Provider struct {
3535 TracerProvider *trace.TracerProvider
3636 MeterProvider *metric.MeterProvider
3737 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
3841 }
3942
4043 // endpointConfig holds parsed endpoint details shared across exporter setup.
@@ -218,5 +221,20 @@ func (p *Provider) setupLogging(ctx context.Context, ep *endpointConfig, res *re
218221 )
219222 p.LogProvider = lp
220223 *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)
221239 return nil
222240 }
@@ -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 export27 Traces bool `mapstructure:"traces"` // Enable trace export
28 Metrics bool `mapstructure:"metrics"` // Enable metric export28 Metrics bool `mapstructure:"metrics"` // Enable metric export
29- Logs bool `mapstructure:"logs"` // Bridge zerowrap logs to OTLP29+ 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 sample30 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.TracerProvider35 TracerProvider *trace.TracerProvider
36 MeterProvider *metric.MeterProvider36 MeterProvider *metric.MeterProvider
37 LogProvider *otellog.LoggerProvider37 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 = lp222 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 nil239 return nil
222 }240 }
M internal/app/run.go +49 -0
@@ -83,6 +83,7 @@ import (
8383 "github.com/bnema/gordon/internal/usecase/deployment"
8484 "github.com/bnema/gordon/internal/usecase/health"
8585 "github.com/bnema/gordon/internal/usecase/images"
86+ "github.com/bnema/gordon/internal/usecase/logexport"
8687 "github.com/bnema/gordon/internal/usecase/logs"
8788 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
8889 "github.com/bnema/gordon/internal/usecase/proxy"
@@ -343,6 +344,9 @@ type services struct {
343344 registryHandler interface {
344345 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
345346 }
347+ // workloadLogs exports proxy access and app container logs over
348+ // OTLP. Nil when telemetry log export is disabled.
349+ workloadLogs out.LogExporter
346350 }
347351
348352 // Run initializes and starts the Gordon application.
@@ -401,6 +405,7 @@ func Run(ctx context.Context, configPath string) error {
401405 if err != nil {
402406 return err
403407 }
408+ svc.workloadLogs = workloadLogExporter(telProvider)
404409
405410 // Register event handlers
406411 cleanupHandlers, err := registerEventHandlers(ctx, svc)
@@ -3023,6 +3028,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
30233028 if accessWriterConcrete != nil {
30243029 accessWriter = accessWriterConcrete
30253030 }
3031+ accessWriter = withAccessLogExport(svc, accessWriter)
30263032 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
30273033 defer cancel()
30283034
@@ -3103,6 +3109,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
31033109 // leaves healthy converged apps returning 404 until a later lifecycle
31043110 // operation happens to rebuild it.
31053111 reconcileAppsAtBoot(ctx, svc, log)
3112+ stopLogExport := startContainerLogExport(ctx, svc, log)
3113+ defer stopLogExport()
31063114
31073115 logEvent := log.Info().
31083116 Int("proxy_port", cfg.Server.Port).
@@ -3154,6 +3162,47 @@ func reconcileAppsAtBoot(ctx context.Context, svc *services, log zerowrap.Logger
31543162 svc.appMonitor.Start(ctx)
31553163 }
31563164
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+
31573206 func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
31583207 if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
31593208 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 err406 return err
403 }407 }
408+ svc.workloadLogs = workloadLogExporter(telProvider)
404 409
405 // Register event handlers410 // 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 = accessWriterConcrete3029 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 lifecycle3109 // 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 {
111111 Devices []string
112112 Databases []AppDatabase
113113 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
114120 }
115121
116122 // AppReadiness is the explicit readiness check for a service.
@@ -1065,12 +1071,21 @@ func diffService(name string, desired, effective AppService) []string {
10651071 changed = append(changed, "service/"+name+"/binds")
10661072 }
10671073 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 {
10681080 if !reflect.DeepEqual(desired.Databases, effective.Databases) {
10691081 changed = append(changed, "service/"+name+"/databases")
10701082 }
10711083 if !reflect.DeepEqual(desired.Backup, effective.Backup) {
10721084 changed = append(changed, "service/"+name+"/backup")
10731085 }
1086+ if desired.LogExportDisabled != effective.LogExportDisabled {
1087+ changed = append(changed, "service/"+name+"/telemetry")
1088+ }
10741089 return changed
10751090 }
10761091
@@ -111,6 +111,12 @@ type AppService struct {
111 Devices []string111 Devices []string
112 Databases []AppDatabase112 Databases []AppDatabase
113 Backup AppBackup113 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 changed1089 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) {
6464 return entry, ok
6565 }
6666
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+
6776 // LookupHost implements proxy.TargetProvider over the host index.
6877 // The proxy package owns the interface; apptraffic implements it to
6978 // keep app-state interpretation out of the proxy.
@@ -64,6 +64,15 @@ func (h *HostIndex) Lookup(host string) (RouteEntry, bool) {
64 return entry, ok64 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 to77 // 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 (
1515 "github.com/bnema/zerowrap"
1616 "go.opentelemetry.io/otel"
1717 "go.opentelemetry.io/otel/attribute"
18- "go.opentelemetry.io/otel/metric"
1918 "go.opentelemetry.io/otel/trace"
2019
21- "github.com/bnema/gordon/internal/adapters/out/telemetry"
2220 "github.com/bnema/gordon/internal/boundaries/out"
2321 "github.com/bnema/gordon/internal/domain"
2422 "github.com/bnema/gordon/internal/usecase/registrystate"
@@ -32,13 +30,13 @@ type Service struct {
3230 blobStorage out.BlobStorage
3331 manifestStorage out.ManifestStorage
3432 eventBus out.EventPublisher
35- metrics *telemetry.Metrics
33+ metrics out.Metrics
3634 mutationMu *sync.RWMutex
3735 registryState *registrystate.State
3836 }
3937
4038 // 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) {
4240 s.metrics = m
4341 }
4442
@@ -132,12 +130,7 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
132130
133131 // Record push metrics
134132 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)))
141134 }
142135
143136 // 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.BlobStorage30 blobStorage out.BlobStorage
33 manifestStorage out.ManifestStorage31 manifestStorage out.ManifestStorage
34 eventBus out.EventPublisher32 eventBus out.EventPublisher
35- metrics *telemetry.Metrics33+ metrics out.Metrics
36 mutationMu *sync.RWMutex34 mutationMu *sync.RWMutex
37 registryState *registrystate.State35 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 = m40 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 metrics131 // 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) {
8888 assert.True(t, strings.HasPrefix(digest, "sha256:"))
8989 }
9090
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+
91111 func TestService_PutManifest_RejectsUnownedBlob(t *testing.T) {
92112 const digest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
93113 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)

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close