Skip to content

Commit 084ed2e

Browse files
authored
Merge pull request #13 from dappnode/feat/diagnosable-errors
feat: classify every error and name the build in logs and metrics
2 parents cffaf37 + 6f325ab commit 084ed2e

29 files changed

Lines changed: 775 additions & 84 deletions

‎.github/workflows/release-and-push.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ jobs:
7676
--label org.opencontainers.image.source=https://github.com/dappnode/dappnode-nexus-gateway \
7777
--label org.opencontainers.image.revision=${{ github.sha }} \
7878
--label org.opencontainers.image.version="$VERSION" \
79+
--build-arg VERSION="$VERSION" \
80+
--build-arg COMMIT=${{ github.sha }} \
7981
-t "$ECR_REGISTRY/$IMAGE_NAME:$VERSION" \
8082
-t "$ECR_REGISTRY/$IMAGE_NAME:latest" \
8183
-t "$ECR_REGISTRY/$IMAGE_NAME:sha-${{ github.sha }}" \

‎apps/gateway/Dockerfile‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ COPY go.mod go.sum ./
77
RUN go mod download
88

99
COPY . .
10-
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gateway ./apps/gateway
10+
# Release builds name themselves; logs and metrics carry these values.
11+
ARG VERSION=dev
12+
ARG COMMIT=
13+
RUN CGO_ENABLED=0 GOOS=linux go build \
14+
-ldflags="-s -w -X github.com/dappnode/dappnode-nexus-gateway/pkg/buildinfo.Version=${VERSION} -X github.com/dappnode/dappnode-nexus-gateway/pkg/buildinfo.Commit=${COMMIT}" \
15+
-o /gateway ./apps/gateway
1116

1217
# ---- Runtime stage ----
1318
FROM alpine:3.20

‎apps/gateway/internal/adapters/http/handlers/chat_completions_handler.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ func NewChatCompletionsHandler(service *services.ChatCompletionsService, logger
3333
func (h *ChatCompletionsHandler) Handle(w http.ResponseWriter, r *http.Request) {
3434
token, err := ExtractBearerToken(r)
3535
if err != nil {
36-
WriteErrorWithLog(w, r, h.logger, err)
36+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
3737
return
3838
}
3939

4040
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize))
4141
if err != nil {
42-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("failed to read request body"))
42+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("failed to read request body"))
4343
return
4444
}
4545

4646
genReq, err := mapper.ChatCompletionRequestToDomain(body)
4747
if err != nil {
48-
WriteErrorWithLog(w, r, h.logger, err)
48+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
4949
return
5050
}
5151

‎apps/gateway/internal/adapters/http/handlers/common.go‎

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/http/dto"
99
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/http/middleware"
10+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/observability/metrics"
1011
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/ports"
1112
"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
1213
)
@@ -43,29 +44,21 @@ func WriteErrorWithLog(w http.ResponseWriter, r *http.Request, logger ports.Logg
4344
"path", r.URL.Path,
4445
"error_type", fmt.Sprintf("%T", err),
4546
)
46-
gwErr = domain.ErrInternal("an internal error occurred")
47+
gwErr = domain.ErrInternal("an internal error occurred").WithMeta("cause", domain.CauseUntypedError)
4748
}
4849

49-
// Log at warn for client errors, error for server errors.
50+
// Log at warn for client errors, error for server errors. Provider errors
51+
// can echo prompts: only the classification and log-safe metadata go in.
52+
fields := []any{
53+
"request_id", middleware.GetRequestID(r.Context()),
54+
"path", r.URL.Path,
55+
"status", gwErr.HTTPStatus,
56+
}
57+
fields = append(fields, domain.DiagnosticFields(gwErr)...)
5058
if gwErr.HTTPStatus >= 500 {
51-
fields := []any{
52-
"request_id", middleware.GetRequestID(r.Context()),
53-
"status", gwErr.HTTPStatus,
54-
"gateway_status", gwErr.HTTPStatus,
55-
"error_code", gwErr.Code,
56-
}
57-
// Provider errors can echo prompts; only retain numeric upstream status.
58-
if status, ok := gwErr.Metadata["upstream_status"].(int); ok {
59-
fields = append(fields, "upstream_status", status)
60-
}
6159
logger.Error("request failed", fields...)
6260
} else if gwErr.HTTPStatus >= 400 {
63-
logger.Warn("request error",
64-
"request_id", middleware.GetRequestID(r.Context()),
65-
"status", gwErr.HTTPStatus,
66-
"gateway_status", gwErr.HTTPStatus,
67-
"error_code", gwErr.Code,
68-
)
61+
logger.Warn("request error", fields...)
6962
}
7063

7164
if gwErr.RetryAfter != "" {
@@ -89,6 +82,14 @@ func errorDetail(err *domain.GatewayError) dto.ErrorDetail {
8982
return detail
9083
}
9184

85+
// rejectRequest answers a generation request that failed before reaching the
86+
// generation service (missing key, unreadable or invalid body), counting it
87+
// with the failures the service records itself.
88+
func rejectRequest(w http.ResponseWriter, r *http.Request, logger ports.Logger, endpoint string, err error) {
89+
metrics.RecordError(endpoint, metrics.UnresolvedModel, "unknown", metrics.PhaseRequest, err)
90+
WriteErrorWithLog(w, r, logger, err)
91+
}
92+
9293
// ExtractBearerToken extracts the bearer token from the Authorization header.
9394
func ExtractBearerToken(r *http.Request) (string, error) {
9495
auth := r.Header.Get("Authorization")

‎apps/gateway/internal/adapters/http/handlers/confidential_chat_completions_handler.go‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,20 @@ func (h *ConfidentialChatCompletionsHandler) Handle(w http.ResponseWriter, r *ht
8080
w.Header().Set("Cache-Control", "no-store, no-transform")
8181

8282
if r.Body == nil || r.Body == http.NoBody || r.ContentLength == 0 {
83-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("confidential request body is required"))
83+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("confidential request body is required"))
8484
return
8585
}
8686
encapsulatedKeys := r.Header.Values(protocol.EncapsulatedKeyHeader)
8787
if len(encapsulatedKeys) == 0 {
88-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("confidential endpoint requires an EHBP-encrypted body"))
88+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("confidential endpoint requires an EHBP-encrypted body"))
8989
return
9090
}
9191
if len(encapsulatedKeys) != 1 || !isCanonicalEHBPKey(encapsulatedKeys[0]) {
92-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("invalid EHBP encapsulated key header"))
92+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("invalid EHBP encapsulated key header"))
9393
return
9494
}
9595
if r.ContentLength > confidentialMaxEHBPBody {
96-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("encrypted request body exceeds the maximum size"))
96+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("encrypted request body exceeds the maximum size"))
9797
return
9898
}
9999

@@ -118,47 +118,48 @@ func isCanonicalEHBPKey(encoded string) bool {
118118
func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWriter, r *http.Request) {
119119
body, err := io.ReadAll(io.LimitReader(r.Body, confidentialEnvelopeLimit+1))
120120
if err != nil {
121-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("failed to read confidential request"))
121+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("failed to read confidential request"))
122122
return
123123
}
124124
if len(body) > confidentialEnvelopeLimit {
125-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("confidential request body exceeds the maximum size"))
125+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("confidential request body exceeds the maximum size"))
126126
return
127127
}
128128

129129
envelope, err := decodeConfidentialEnvelope(body)
130130
if err != nil {
131-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField(err.Error()))
131+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField(err.Error()))
132132
return
133133
}
134134

135135
now := h.now().UTC()
136136
issuedAt, err := time.Parse(time.RFC3339, envelope.IssuedAt)
137137
if err != nil {
138-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("issued_at must be an RFC3339 timestamp"))
138+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("issued_at must be an RFC3339 timestamp"))
139139
return
140140
}
141141
if issuedAt.Before(now.Add(-confidentialMaxAge)) {
142-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("confidential request is stale"))
142+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("confidential request is stale"))
143143
return
144144
}
145145
if issuedAt.After(now.Add(confidentialFutureSkew)) {
146-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("confidential request timestamp is in the future"))
146+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("confidential request timestamp is in the future"))
147147
return
148148
}
149149

150150
parsedID, err := uuid.Parse(envelope.RequestID)
151151
if err != nil || parsedID == uuid.Nil || parsedID.String() != envelope.RequestID {
152-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("request_id must be a canonical UUID"))
152+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("request_id must be a canonical UUID"))
153153
return
154154
}
155155

156156
switch h.replays.checkAndStore(envelope.RequestID, now) {
157157
case replayDuplicate:
158-
WriteErrorWithLog(w, r, h.logger, domain.ErrConflict("confidential request was already processed"))
158+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrConflict("confidential request was already processed"))
159159
return
160160
case replayCapacityReached:
161-
WriteErrorWithLog(w, r, h.logger, domain.ErrProviderUnavailable("confidential replay protection"))
161+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrProviderUnavailable("confidential replay protection").WithMeta(
162+
"fault", domain.FaultNexus, "dependency", "replay_store", "cause", domain.CauseReplayStoreFull))
162163
return
163164
}
164165

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package handlers
2+
3+
import (
4+
"net/http/httptest"
5+
"strings"
6+
"testing"
7+
8+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/observability/metrics"
9+
"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
10+
"github.com/prometheus/client_golang/prometheus/testutil"
11+
)
12+
13+
// Requests the handler rejects before the generation service sees them are
14+
// counted with the service's own failures.
15+
func TestChatHandlerCountsRejectedRequests(t *testing.T) {
16+
logger := &privacyLogger{}
17+
handler := NewChatCompletionsHandler(nil, logger)
18+
for name, tc := range map[string]struct {
19+
auth, body, code string
20+
}{
21+
"missing key": {body: `{"model":"m","messages":[]}`, code: domain.ErrCodeInvalidAPIKey},
22+
"invalid body": {auth: "Bearer sk-test", body: `{`, code: domain.ErrCodeInvalidField},
23+
} {
24+
t.Run(name, func(t *testing.T) {
25+
counter := metrics.ErrorsTotal.WithLabelValues(domain.EndpointChatCompletions, metrics.UnresolvedModel, "unknown",
26+
metrics.PhaseRequest, domain.FaultCaller, tc.code, "none")
27+
before := testutil.ToFloat64(counter)
28+
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(tc.body))
29+
if tc.auth != "" {
30+
req.Header.Set("Authorization", tc.auth)
31+
}
32+
handler.Handle(httptest.NewRecorder(), req)
33+
if got := testutil.ToFloat64(counter) - before; got != 1 {
34+
t.Fatalf("errors_total delta = %v, want 1", got)
35+
}
36+
})
37+
}
38+
}
39+
40+
// Error lines carry the classification, and provider text that is not an
41+
// identifier never reaches the log.
42+
func TestErrorLogsCarryClassification(t *testing.T) {
43+
const secret = "PRIVATE PROMPT CANARY"
44+
logger := &privacyLogger{}
45+
err := domain.ErrInternal("an internal error occurred").WithMeta("dependency", "pii", "cause", domain.CausePIIFilterTimeout,
46+
"upstream_code", secret, "account_id", "acc_1")
47+
WriteErrorWithLog(httptest.NewRecorder(), httptest.NewRequest("POST", "/v1/chat/completions", nil), logger, err)
48+
out := logger.output.String()
49+
for _, want := range []string{"fault nexus", "cause pii_filter_timeout", "dependency pii", "account_id acc_1"} {
50+
if !strings.Contains(out, want) {
51+
t.Errorf("log %q lacks %q", out, want)
52+
}
53+
}
54+
if strings.Contains(out, secret) {
55+
t.Fatal("error log leaked provider text")
56+
}
57+
}

‎apps/gateway/internal/adapters/http/handlers/systemone_handler.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ func NewSystemOneHandler(service *services.GenerateService, logger ports.Logger)
2222
func (h *SystemOneHandler) Handle(w http.ResponseWriter, r *http.Request) {
2323
token, err := ExtractBearerToken(r)
2424
if err != nil {
25-
WriteErrorWithLog(w, r, h.logger, err)
25+
rejectRequest(w, r, h.logger, domain.EndpointSystemOne, err)
2626
return
2727
}
2828
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBodySize))
2929
if err != nil {
30-
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("request body unreadable or too large"))
30+
rejectRequest(w, r, h.logger, domain.EndpointSystemOne, domain.ErrInvalidField("request body unreadable or too large"))
3131
return
3232
}
3333
req, err := mapper.SystemOneRequestToDomain(body)
3434
if err != nil {
35-
WriteErrorWithLog(w, r, h.logger, err)
35+
rejectRequest(w, r, h.logger, domain.EndpointSystemOne, err)
3636
return
3737
}
3838
call, err := h.service.Proxy(r.Context(), domain.EndpointSystemOne, body, req, token)

‎apps/gateway/internal/adapters/http/middleware/metrics.go‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ import (
1212
// intentionally placed as the outermost middleware so the recorded duration
1313
// covers the full handler chain.
1414
//
15-
// The route label is read from r.Pattern after the handler runs, because the
16-
// stdlib ServeMux only populates it when dispatching to a matched route
17-
// handler. Requests that match no route use the fixed "unmatched" label so a
18-
// flood of 404s cannot create unbounded series.
19-
func Metrics(next http.Handler) http.Handler {
15+
// The route label comes from route, which names the mux pattern a request
16+
// matches: inner middleware passes the mux a copy of the request, so
17+
// r.Pattern is never set on the request seen here. Requests that match no
18+
// route use the fixed "unmatched" label so a flood of 404s cannot create
19+
// unbounded series.
20+
func Metrics(route func(*http.Request) string) func(http.Handler) http.Handler {
21+
return func(next http.Handler) http.Handler {
22+
return metricsHandler(next, route)
23+
}
24+
}
25+
26+
func metricsHandler(next http.Handler, routeOf func(*http.Request) string) http.Handler {
2027
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2128
mw := &metricsWriter{ResponseWriter: w, status: http.StatusOK}
2229

@@ -26,7 +33,7 @@ func Metrics(next http.Handler) http.Handler {
2633
duration := time.Since(start).Seconds()
2734
metrics.HTTPInflight.Dec()
2835

29-
route := r.Pattern
36+
route := routeOf(r)
3037
if route == "" {
3138
route = "unmatched"
3239
}

‎apps/gateway/internal/adapters/http/router.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ func NewRouter(
4242
handler = middleware.Logging(logger)(handler)
4343
handler = middleware.Recovery(logger)(handler)
4444
handler = middleware.RequestID(handler)
45-
handler = middleware.Metrics(handler)
45+
// Middleware hands the mux a copy of the request, so the route pattern is
46+
// read from the mux rather than from the request the metrics see.
47+
handler = middleware.Metrics(func(r *http.Request) string {
48+
_, pattern := mux.Handler(r)
49+
return pattern
50+
})(handler)
4651

4752
return handler
4853
}

‎apps/gateway/internal/adapters/http/router_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"testing"
88

99
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/http/handlers"
10+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/observability/metrics"
11+
"github.com/prometheus/client_golang/prometheus/testutil"
1012
)
1113

1214
type routerTestLogger struct{}
@@ -56,3 +58,23 @@ func TestNewRouter_PreservesLegacyChatRouteWithoutConfidentialHandler(t *testing
5658
t.Fatalf("unauthenticated EHBP key discovery status = %d, want 404", keyDiscoveryResponse.Code)
5759
}
5860
}
61+
62+
// Requests are counted under the route they matched, though the middleware
63+
// in between hands the mux a copy of the request.
64+
func TestNewRouter_LabelsMetricsWithTheMatchedRoute(t *testing.T) {
65+
logger := &routerTestLogger{}
66+
router := NewRouter(handlers.NewHealthHandler(), handlers.NewModelsHandler(nil), handlers.NewChatCompletionsHandler(nil, logger), nil, nil, nil, nil, logger)
67+
health := metrics.HTTPRequestsTotal.WithLabelValues("GET /healthz", http.MethodGet, "2xx")
68+
unmatched := metrics.HTTPRequestsTotal.WithLabelValues("unmatched", http.MethodGet, "4xx")
69+
healthBefore, unmatchedBefore := testutil.ToFloat64(health), testutil.ToFloat64(unmatched)
70+
71+
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/healthz", nil))
72+
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/nope", nil))
73+
74+
if got := testutil.ToFloat64(health) - healthBefore; got != 1 {
75+
t.Fatalf("GET /healthz count delta = %v, want 1", got)
76+
}
77+
if got := testutil.ToFloat64(unmatched) - unmatchedBefore; got != 1 {
78+
t.Fatalf("unmatched count delta = %v, want 1", got)
79+
}
80+
}

0 commit comments

Comments
 (0)