Skip to content

Commit f31bfe8

Browse files
authored
feat: OpenTelemetry integration (#14)
* feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration * feat: OTLP integration
1 parent 0d649d2 commit f31bfe8

35 files changed

Lines changed: 1989 additions & 90 deletions

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ dev-init:
300300
.PHONY: dev-deps
301301
dev-deps:
302302
@echo "Starting service dependencies..."
303-
$(COMPOSE_CMD) up -d postgres redis redis-insight localstack adminer
304-
@echo "Dependencies started (postgres:5432, redis:6379, redis-insight:5540, localstack/dynamodb:4566, adminer:8081)"
303+
$(COMPOSE_CMD) up -d postgres redis redis-insight localstack adminer aspire-dashboard
304+
@echo "Dependencies started (postgres:5432, redis:6379, redis-insight:5540, localstack/dynamodb:4566, adminer:8081, aspire-dashboard:18888)"
305305

306306
.PHONY: dev
307307
dev: dev-deps

README.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ Modern CI/CD pipelines and automation workflows frequently require access to Git
1010
- [2. Key capabilities](#key-capabilities)
1111
- [3. Getting started](#getting-started)
1212
- [4. Configuration](#configuration)
13-
- [5. API reference](#api-reference)
14-
- [6. Development](#development)
15-
- [7. Deployment](#deployment)
16-
- [8. FIPS 140-3 compliance](#fips-140-3-compliance)
17-
- [9. Security considerations](#security-considerations)
18-
- [10. Contributing](#contributing)
19-
- [11. License](#license)
13+
- [5. Observability](#observability)
14+
- [6. API reference](#api-reference)
15+
- [7. Development](#development)
16+
- [8. Deployment](#deployment)
17+
- [9. FIPS 140-3 compliance](#fips-140-3-compliance)
18+
- [10. Security considerations](#security-considerations)
19+
- [11. Contributing](#contributing)
20+
- [12. License](#license)
2021

2122
## How it works
2223

@@ -241,6 +242,48 @@ origin:
241242

242243
The shared secret is injected via the `GATE_ORIGIN_HEADER_VALUE` environment variable. See the [Helm chart documentation](deployments/helm/gate/README.md) for Kubernetes configuration and the [Terraform modules](deployments/terraform/modules/aws/README.md) for CloudFront setup.
243244

245+
## Observability
246+
247+
GATE has built-in OpenTelemetry instrumentation for traces, metrics, and logs, exported via OTLP/gRPC. It is **disabled by default**; enable it via the `otel:` config block or `GATE_OTEL_*` environment variables.
248+
249+
### What is instrumented
250+
251+
- **HTTP server**: every request to `/api/v1/*` produces a span (`otelhttp.NewMiddleware`) with the matched chi route as `http.route`.
252+
- **Outbound HTTP**: GitHub API and OIDC discovery/JWKS calls.
253+
- **Database**: PostgreSQL (via `gorm.io/plugin/opentelemetry/tracing`) and DynamoDB (via `otelaws`).
254+
- **Redis**: traces and metrics via `redisotel`.
255+
- **Manual spans**: `TokenExchange` (handler), and `ValidateOIDC`, `EvaluatePolicy`, `SelectApp`, `MintInstallationToken` (service).
256+
- **Metrics**: `token_exchange_total{outcome}`, `token_exchange_duration_seconds`, `token_issued_total{repository}`, `caller_exchange_total{sub,issuer,outcome}`, plus Go runtime metrics.
257+
- **Logs**: when enabled, slog records are exported via OTLP **in addition to** stdout. Records always carry `trace_id` and `span_id` when emitted inside an active span.
258+
259+
### Configuration
260+
261+
| Key | Env var | Default | Notes |
262+
|---|---|---|---|
263+
| `otel.enabled` | `GATE_OTEL_ENABLED` | `false` | Master switch. |
264+
| `otel.service_name` | `GATE_OTEL_SERVICE_NAME` | `gate` | Becomes `service.name` resource attr. |
265+
| `otel.endpoint` | `GATE_OTEL_ENDPOINT` | `localhost:4317` | OTLP/gRPC collector. |
266+
| `otel.protocol` | `GATE_OTEL_PROTOCOL` | `grpc` | Only `grpc` supported. |
267+
| `otel.insecure` | `GATE_OTEL_INSECURE` | `false` | Disable TLS to collector. Set to `true` for local development. |
268+
| `otel.sample_rate` | `GATE_OTEL_SAMPLE_RATE` | `1.0` | Trace sampler ratio (0.0-1.0). |
269+
| `otel.exporter_timeout` | `GATE_OTEL_EXPORTER_TIMEOUT` | `10s` | OTLP exporter timeout per batch flush. |
270+
271+
### Local development
272+
273+
The local compose stack ships with [Microsoft Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview) — a single image that bundles an OTLP receiver and a web UI for browsing traces, metrics, and logs.
274+
275+
```bash
276+
# Start the dashboard (OTLP/gRPC on :4317, UI on :18888)
277+
docker compose -f local/compose.dev.yml up aspire-dashboard -d
278+
279+
# Run gate with OTel env vars sourced
280+
set -a; source local/otel.env; set +a
281+
go run . server -c config.yaml
282+
283+
# Open the dashboard
284+
open http://localhost:18888
285+
```
286+
244287
## API reference
245288

246289
### Exchange endpoint

cmd/server/handlers/exchange.go

Lines changed: 122 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,22 @@ package handlers
1717

1818
import (
1919
"errors"
20+
"fmt"
21+
"log/slog"
2022
"net/http"
2123
"strconv"
24+
"strings"
25+
"time"
2226

2327
"github.com/ggicci/httpin"
2428
"github.com/go-chi/chi/v5/middleware"
2529
"github.com/go-chi/render"
2630
"github.com/thomsonreuters/gate/internal/sts"
31+
"go.opentelemetry.io/otel"
32+
"go.opentelemetry.io/otel/attribute"
33+
"go.opentelemetry.io/otel/codes"
34+
"go.opentelemetry.io/otel/metric"
35+
"go.opentelemetry.io/otel/trace"
2736
)
2837

2938
// ExchangeInput is the httpin-decoded input for the exchange endpoint.
@@ -33,7 +42,12 @@ type ExchangeInput struct {
3342

3443
// ExchangeHandler handles POST /api/v1/exchange requests.
3544
type ExchangeHandler struct {
36-
service sts.Exchanger
45+
service sts.Exchanger
46+
tracer trace.Tracer
47+
exchangeCount metric.Int64Counter
48+
exchangeDuration metric.Float64Histogram
49+
tokenIssuedCount metric.Int64Counter
50+
callerExchangeCount metric.Int64Counter
3751
}
3852

3953
// ErrorResponse is the JSON body returned for all exchange failures.
@@ -44,12 +58,43 @@ type ErrorResponse struct {
4458
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
4559
}
4660

47-
// maxBodySize is the maximum request body size in bytes.
48-
const maxBodySize = 1 << 20 // 1 MB
49-
5061
// NewExchangeHandler creates an ExchangeHandler with the given STS service.
51-
func NewExchangeHandler(service sts.Exchanger) *ExchangeHandler {
52-
return &ExchangeHandler{service: service}
62+
// It pulls a tracer and meters from the global OTel providers.
63+
func NewExchangeHandler(service sts.Exchanger) (*ExchangeHandler, error) {
64+
meter := otel.GetMeterProvider().Meter("exchange")
65+
counter, err := meter.Int64Counter("token_exchange_total",
66+
metric.WithDescription("Token exchange requests by outcome"))
67+
if err != nil {
68+
return nil, fmt.Errorf("registering token exchange counter: %w", err)
69+
}
70+
71+
histogram, err := meter.Float64Histogram("token_exchange_duration_seconds",
72+
metric.WithUnit("s"),
73+
metric.WithDescription("Token exchange handler latency in seconds"))
74+
if err != nil {
75+
return nil, fmt.Errorf("registering token exchange duration histogram: %w", err)
76+
}
77+
78+
tokenIssued, err := meter.Int64Counter("token_issued_total",
79+
metric.WithDescription("Tokens issued by repository"))
80+
if err != nil {
81+
return nil, fmt.Errorf("registering token issued counter: %w", err)
82+
}
83+
84+
callerExchange, err := meter.Int64Counter("caller_exchange_total",
85+
metric.WithDescription("Exchange attempts by caller identity and outcome"))
86+
if err != nil {
87+
return nil, fmt.Errorf("registering caller exchange counter: %w", err)
88+
}
89+
90+
return &ExchangeHandler{
91+
service: service,
92+
tracer: otel.GetTracerProvider().Tracer("exchange"),
93+
exchangeCount: counter,
94+
exchangeDuration: histogram,
95+
tokenIssuedCount: tokenIssued,
96+
callerExchangeCount: callerExchange,
97+
}, nil
5398
}
5499

55100
// httpStatusCode maps STS error codes to HTTP status codes for API responses.
@@ -77,43 +122,89 @@ func httpStatusCode(code string) int {
77122

78123
// Exchange delegates to the STS service and writes the response.
79124
func (h *ExchangeHandler) Exchange(w http.ResponseWriter, r *http.Request) {
80-
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
81125
requestID := middleware.GetReqID(r.Context())
126+
ctx, span := h.tracer.Start(r.Context(), "TokenExchange")
127+
defer span.End()
128+
129+
start := time.Now()
130+
outcome := "ok"
131+
var callerIssuer, callerSubject string
132+
133+
defer func() {
134+
r := recover()
135+
if r != nil {
136+
outcome = "panic"
137+
span.SetStatus(codes.Error, "handler panic")
138+
span.SetAttributes(attribute.String("panic.value", fmt.Sprintf("%v", r)))
139+
slog.ErrorContext(ctx, "Exchange handler panic", slog.Any("panic", r))
140+
}
141+
h.exchangeDuration.Record(ctx, time.Since(start).Seconds())
142+
h.exchangeCount.Add(ctx, 1, metric.WithAttributes(attribute.String("outcome", outcome)))
143+
if callerIssuer != "" || callerSubject != "" {
144+
h.callerExchangeCount.Add(ctx, 1, metric.WithAttributes(
145+
attribute.String("sub", callerSubject),
146+
attribute.String("issuer", callerIssuer),
147+
attribute.String("outcome", outcome),
148+
))
149+
}
150+
if r != nil {
151+
panic(r)
152+
}
153+
}()
82154

83155
input, ok := r.Context().Value(httpin.Input).(*ExchangeInput)
84156
if !ok {
85-
render.Status(r, http.StatusBadRequest)
86-
render.JSON(w, r, &ErrorResponse{Code: sts.ErrInvalidRequest, Message: "Invalid request", RequestID: requestID})
157+
outcome = h.writeError(w, r, span, sts.ErrInvalidRequest, "Invalid request", requestID, 0, nil)
87158
return
88159
}
160+
span.SetAttributes(attribute.String("repository", input.Body.TargetRepository))
89161

90-
resp, err := h.service.Exchange(r.Context(), requestID, &input.Body)
162+
response, err := h.service.Exchange(ctx, requestID, &input.Body)
91163
if err != nil {
92-
var exchangeErr *sts.ExchangeError
93-
if errors.As(err, &exchangeErr) {
94-
status := httpStatusCode(exchangeErr.Code)
95-
if exchangeErr.Code == sts.ErrRateLimited && exchangeErr.RetryAfterSeconds > 0 {
96-
w.Header().Set("Retry-After", strconv.Itoa(exchangeErr.RetryAfterSeconds))
97-
}
98-
render.Status(r, status)
99-
render.JSON(w, r, &ErrorResponse{
100-
Code: exchangeErr.Code,
101-
Message: exchangeErr.Message,
102-
RequestID: exchangeErr.RequestID,
103-
RetryAfterSeconds: exchangeErr.RetryAfterSeconds,
104-
})
164+
var exErr *sts.ExchangeError
165+
if errors.As(err, &exErr) {
166+
callerIssuer = exErr.Issuer
167+
callerSubject = exErr.Subject
168+
outcome = h.writeError(w, r, span, exErr.Code, exErr.Message, exErr.RequestID, exErr.RetryAfterSeconds, err)
105169
return
106170
}
107-
108-
render.Status(r, http.StatusInternalServerError)
109-
render.JSON(w, r, &ErrorResponse{
110-
Code: sts.ErrInternalError,
111-
Message: "Internal server error",
112-
RequestID: requestID,
113-
})
171+
outcome = h.writeError(w, r, span, sts.ErrInternalError, "Internal server error", requestID, 0, err)
114172
return
115173
}
116174

175+
callerIssuer = response.Issuer
176+
callerSubject = response.Subject
177+
h.tokenIssuedCount.Add(ctx, 1, metric.WithAttributes(
178+
attribute.String("repository", input.Body.TargetRepository),
179+
))
180+
181+
span.SetAttributes(
182+
attribute.String("matched_policy", response.MatchedPolicy),
183+
)
184+
117185
render.Status(r, http.StatusOK)
118-
render.JSON(w, r, resp)
186+
render.JSON(w, r, response)
187+
}
188+
189+
// writeError renders a JSON error response, records the failure on the
190+
// span, and returns the metric outcome label (lowercased error code).
191+
func (h *ExchangeHandler) writeError(w http.ResponseWriter, r *http.Request, span trace.Span,
192+
code, message, requestID string, retryAfter int, err error) string {
193+
if err != nil {
194+
span.RecordError(err)
195+
}
196+
span.SetStatus(codes.Error, message)
197+
198+
if code == sts.ErrRateLimited && retryAfter > 0 {
199+
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
200+
}
201+
202+
render.Status(r, httpStatusCode(code))
203+
render.JSON(w, r, &ErrorResponse{
204+
Code: code,
205+
Message: message,
206+
RequestID: requestID,
207+
RetryAfterSeconds: retryAfter,
208+
})
209+
return strings.ToLower(code)
119210
}

cmd/server/handlers/exchange_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ func (m *mockService) Exchange(_ context.Context, _ string, _ *sts.ExchangeReque
4242

4343
func TestNewExchangeHandler(t *testing.T) {
4444
t.Parallel()
45-
h := NewExchangeHandler(&mockService{})
45+
h, hErr := NewExchangeHandler(&mockService{})
46+
require.NoError(t, hErr)
4647
require.NotNil(t, h)
4748
}
4849

@@ -63,7 +64,8 @@ func TestHTTPStatusCode(t *testing.T) {
6364

6465
func TestExchange_MissingInput(t *testing.T) {
6566
t.Parallel()
66-
h := NewExchangeHandler(&mockService{})
67+
h, hErr := NewExchangeHandler(&mockService{})
68+
require.NoError(t, hErr)
6769

6870
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/exchange", nil)
6971
w := httptest.NewRecorder()
@@ -89,7 +91,8 @@ func TestExchange_Success(t *testing.T) {
8991
RequestID: "req-123",
9092
},
9193
}
92-
h := NewExchangeHandler(svc)
94+
h, hErr := NewExchangeHandler(svc)
95+
require.NoError(t, hErr)
9396

9497
input := &ExchangeInput{
9598
Body: sts.ExchangeRequest{
@@ -167,7 +170,8 @@ func TestExchange_ExchangeError(t *testing.T) {
167170
t.Parallel()
168171

169172
svc := &mockService{err: tt.err}
170-
h := NewExchangeHandler(svc)
173+
h, hErr := NewExchangeHandler(svc)
174+
require.NoError(t, hErr)
171175

172176
input := &ExchangeInput{
173177
Body: sts.ExchangeRequest{OIDCToken: "tok", TargetRepository: "org/repo"},
@@ -200,7 +204,8 @@ func TestExchange_RateLimitedWithRetryAfter(t *testing.T) {
200204
RetryAfterSeconds: 60,
201205
},
202206
}
203-
h := NewExchangeHandler(svc)
207+
h, hErr := NewExchangeHandler(svc)
208+
require.NoError(t, hErr)
204209

205210
input := &ExchangeInput{
206211
Body: sts.ExchangeRequest{OIDCToken: "tok", TargetRepository: "org/repo"},
@@ -220,7 +225,8 @@ func TestExchange_NonExchangeError(t *testing.T) {
220225
t.Parallel()
221226

222227
svc := &mockService{err: errors.New("unexpected")}
223-
h := NewExchangeHandler(svc)
228+
h, hErr := NewExchangeHandler(svc)
229+
require.NoError(t, hErr)
224230

225231
input := &ExchangeInput{
226232
Body: sts.ExchangeRequest{OIDCToken: "tok", TargetRepository: "org/repo"},

cmd/server/middlewares/otel.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2026 Thomson Reuters
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package middlewares
16+
17+
import (
18+
"net/http"
19+
20+
"github.com/go-chi/chi/v5"
21+
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
22+
"go.opentelemetry.io/otel/attribute"
23+
)
24+
25+
// ChiRouteLabeler attaches the matched chi route pattern as the
26+
// "http.route" attribute on the active otelhttp span. It must run after
27+
// otelhttp.NewMiddleware so the labeler is present in the request context.
28+
func ChiRouteLabeler(next http.Handler) http.Handler {
29+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30+
next.ServeHTTP(w, r)
31+
rctx := chi.RouteContext(r.Context())
32+
if rctx == nil {
33+
return
34+
}
35+
route := rctx.RoutePattern()
36+
if route == "" {
37+
return
38+
}
39+
if labeler, ok := otelhttp.LabelerFromContext(r.Context()); ok {
40+
labeler.Add(attribute.String("http.route", route))
41+
}
42+
})
43+
}

0 commit comments

Comments
 (0)