Skip to content

Commit 7656a5a

Browse files
author
Adam Fisk
committed
egress: make the exporter-failure branch reachable, and pin what the tests claim
Round four on #417. The failure test added last round stopped covering its branch the moment I added endpoint pre-validation: "bad host" fails redactEndpoint, so enableOTELLogs exits at the earlier warning and the sanitizing path is never reached. Removing the sanitization would have left it green. That is the third test in this PR to quietly stop testing what it claims, so this one is structural rather than input-dependent: newLogExporter is indirected — the same shape as initMetricsFn — and the test stubs it to return an error quoting the endpoint. There is no environment value that reaches this branch, because anything malformed enough to break otlploghttp is now refused earlier and otlploghttp prefers logging and falling back to returning an error. redactEndpoint now accepts only http and https. This exporter is OTLP over HTTP, so "ftp://collector/v1/logs" parses fine and would have been announced as enabled while being unsendable. peerAttrs' country test only checked that donor_country existed, so it would have stayed green if the lookup reverted to geolocating transportAddr — which behind Caddy is loopback, reporting every donor as unknown while still populating the attribute. Now stubs the lookup and asserts the forwarded address wins.
1 parent 2dbe0ae commit 7656a5a

3 files changed

Lines changed: 77 additions & 15 deletions

File tree

egress/otellogs.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func enableOTELLogs(ctx context.Context) func(context.Context) error {
8282
return func(context.Context) error { return nil }
8383
}
8484

85-
exp, err := otlploghttp.New(ctx)
85+
exp, err := newLogExporter(ctx)
8686
if err != nil {
8787
// The exporter reports what it could not parse, which for an endpoint
8888
// problem is the endpoint — credentials included. Substituted rather
@@ -144,6 +144,14 @@ func enableOTELLogs(ctx context.Context) func(context.Context) error {
144144
// hold up process exit.
145145
const logShutdownTimeout = 5 * time.Second
146146

147+
// newLogExporter is indirected so the failure branch below is reachable from a
148+
// test. otlploghttp.New declines to fail for most bad input — it logs through
149+
// the SDK's error handler and falls back — so there is no environment value
150+
// that exercises the sanitizing path. Same shape as initMetricsFn in metrics.go.
151+
var newLogExporter = func(ctx context.Context) (sdklog.Exporter, error) {
152+
return otlploghttp.New(ctx)
153+
}
154+
147155
// otlpLogsEndpointVars are the variables that can supply a logs endpoint, in
148156
// OTEL's own precedence order: signal-specific first, then shared.
149157
//
@@ -214,16 +222,21 @@ func (h *teeHandler) WithGroup(name string) slog.Handler {
214222
// is read by more people than the config is. Scheme, host and path are what make
215223
// the line useful.
216224
//
217-
// Anything that is not an absolute URL is refused outright rather than
218-
// returned. url.Parse accepts opaque strings ("secret", "http:token") and
225+
// Anything that is not an absolute http/https URL is refused outright rather
226+
// than returned. url.Parse accepts opaque strings ("secret", "http:token") and
219227
// network-path references ("//s3cr3t", which parses with that as the Host and
220228
// no scheme) without error, and clearing User does nothing to any of them, so
221229
// returning the parsed form would echo the whole value. Requiring both a scheme
222230
// and a host is what makes the redaction meaningful; with no structure to rely
223231
// on there is no way to tell which part was secret.
224232
func redactEndpoint(raw string) (string, bool) {
225233
u, err := url.Parse(raw)
226-
if err != nil || u.Scheme == "" || u.Host == "" {
234+
if err != nil || u.Host == "" {
235+
return "", false
236+
}
237+
// http/https only: this exporter is OTLP over HTTP, so "ftp://collector"
238+
// parses fine and would be announced as enabled while being unsendable.
239+
if u.Scheme != "http" && u.Scheme != "https" {
227240
return "", false
228241
}
229242
u.User = nil

egress/otellogs_test.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package egress
33
import (
44
"bytes"
55
"context"
6+
"fmt"
67
"log/slog"
78
"net/http"
89
"net/http/httptest"
910
"net/url"
1011
"strings"
1112
"testing"
1213
"time"
14+
15+
sdklog "go.opentelemetry.io/otel/sdk/log"
1316
)
1417

1518
// recordingHandler captures what a leg of the tee actually received.
@@ -419,6 +422,7 @@ func TestRedactEndpoint(t *testing.T) {
419422
{"hostless path is refused", "/v1/logs?api-key=SECRET", "(unparseable)"},
420423
{"empty is refused", "", "(unparseable)"},
421424
{"network-path reference is refused", "//s3cr3t", "(unparseable)"},
425+
{"non-http scheme is refused", "ftp://collector/v1/logs", "(unparseable)"},
422426
} {
423427
t.Run(tc.name, func(t *testing.T) {
424428
got, ok := redactEndpoint(tc.in)
@@ -437,15 +441,25 @@ func TestRedactEndpoint(t *testing.T) {
437441

438442
// The exporter-construction failure path, which is where a credential is most
439443
// likely to appear: a malformed-endpoint error is exactly the one that quotes
440-
// the endpoint back. Untested, a regression to passing err straight to slog
441-
// would leave the suite green while writing the credential to the journal.
444+
// the endpoint back.
445+
//
446+
// The constructor is stubbed because no environment value reaches this branch —
447+
// anything malformed enough to break otlploghttp is refused earlier by
448+
// redactEndpoint, and otlploghttp itself prefers logging and falling back to
449+
// returning an error. An earlier version of this test used a malformed endpoint
450+
// and silently stopped covering the branch once that validation was added.
442451
func TestEnableOTELLogs_FailureDoesNotEchoTheEndpoint(t *testing.T) {
443-
// Parses well enough to get past the configured-endpoint guard, and badly
444-
// enough that the exporter refuses it.
445-
const bad = "http://user:s3cr3t@bad host:99999/v1/logs"
446-
t.Setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", bad)
452+
const endpoint = "https://user:s3cr3t@collector.example/v1/logs"
453+
t.Setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", endpoint)
447454
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
448455

456+
prevCtor := newLogExporter
457+
t.Cleanup(func() { newLogExporter = prevCtor })
458+
// Quotes the endpoint back, exactly as an OTLP endpoint error does.
459+
newLogExporter = func(context.Context) (sdklog.Exporter, error) {
460+
return nil, fmt.Errorf("invalid endpoint %q: nope", endpoint)
461+
}
462+
449463
var stderr bytes.Buffer
450464
prev := slog.Default()
451465
slog.SetDefault(slog.New(slog.NewTextHandler(&stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))
@@ -458,12 +472,15 @@ func TestEnableOTELLogs_FailureDoesNotEchoTheEndpoint(t *testing.T) {
458472
if strings.Contains(out, "s3cr3t") {
459473
t.Errorf("the endpoint's credential reached the journal: %q", out)
460474
}
461-
if strings.Contains(out, bad) {
475+
if strings.Contains(out, endpoint) {
462476
t.Errorf("the raw endpoint reached the journal: %q", out)
463477
}
464-
// Whatever happened, it has to be explained — silently doing nothing is the
465-
// failure mode this whole file exists to avoid.
466-
if !strings.Contains(out, "Log export") {
467-
t.Errorf("nothing explained the outcome: %q", out)
478+
// The diagnostic has to survive the sanitizing, or the operator learns
479+
// nothing about why export is off.
480+
if !strings.Contains(out, "could not build the OTLP log exporter") {
481+
t.Errorf("the failure was not explained: %q", out)
482+
}
483+
if !strings.Contains(out, "collector.example/v1/logs") {
484+
t.Errorf("the redacted endpoint is missing, so the line is not actionable: %q", out)
468485
}
469486
}

egress/refusals_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package egress
22

33
import (
44
"fmt"
5+
"net"
56
"net/http/httptest"
67
"runtime"
78
"strings"
@@ -592,3 +593,34 @@ func TestShouldLogRefusal_Concurrent(t *testing.T) {
592593
t.Errorf("suppressed = %d, want %d — a concurrent increment was lost", suppressed, want)
593594
}
594595
}
596+
597+
// The forwarded address is what gets geolocated, not the socket's. Behind Caddy
598+
// RemoteAddr is always loopback, so a regression to geolocating it would report
599+
// every donor as unknown while still populating the attribute — which the
600+
// existence check above would not notice.
601+
func TestPeerAttrs_GeolocatesTheForwardedDonorNotTheProxy(t *testing.T) {
602+
orig := lookupDonorGeo()
603+
t.Cleanup(func() { setDonorGeo(orig) })
604+
setDonorGeo(fakeCountryLookup{"203.0.113.7": "SE", "127.0.0.1": "ZZ"})
605+
606+
r := httptest.NewRequest("GET", "/ws", nil)
607+
r.RemoteAddr = "127.0.0.1:54321"
608+
r.Header.Set("X-Forwarded-For", "203.0.113.7")
609+
610+
kv := map[string]any{}
611+
attrs := peerAttrs(r)
612+
for i := 0; i < len(attrs); i += 2 {
613+
kv[attrs[i].(string)] = attrs[i+1]
614+
}
615+
if got := kv["donor_country"]; got != "SE" {
616+
t.Errorf("donor_country = %v, want SE — the proxy's address was geolocated instead of the donor's", got)
617+
}
618+
}
619+
620+
// fakeCountryLookup resolves the addresses a test names and nothing else.
621+
type fakeCountryLookup map[string]string
622+
623+
func (f fakeCountryLookup) CountryCode(ip net.IP) string { return f[ip.String()] }
624+
func (f fakeCountryLookup) ISP(ip net.IP) string { return "" }
625+
func (f fakeCountryLookup) ASN(ip net.IP) string { return "" }
626+
func (f fakeCountryLookup) ASName(ip net.IP) string { return "" }

0 commit comments

Comments
 (0)