1package logexport
2
3import (
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.
16type 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.
24type AccessLogExporter struct {
25 hosts HostOwnerResolver
26 exporter out.LogExporter
27 next out.AccessLogWriter
28}
29
30var _ out.AccessLogWriter = (*AccessLogExporter)(nil)
31
32// NewAccessLogExporter creates the exporter. next, when non-nil, also
33// receives every entry (the local access log sink).
34func 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.
40func (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
48func (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
78func stripPort(hostport string) string {
79 if host, _, err := net.SplitHostPort(hostport); err == nil {
80 return host
81 }
82 return hostport
83}
84
85func 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}