Skip to content

Commit a2ef55a

Browse files
committed
Wire Railway OTLP env sync and proofing
1 parent be85c5b commit a2ef55a

7 files changed

Lines changed: 688 additions & 1 deletion

File tree

.env.production.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Railway root env template for hosted Wasteland OTLP export.
2+
#
3+
# Keep real secrets in Railway Variables, not in git. The service-scoped
4+
# variables below reference a shared Railway variable named OTLP_SHARED_TOKEN.
5+
6+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otel.cloud.gascityhall.com/v1/traces
7+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://otel.cloud.gascityhall.com/v1/metrics
8+
OTEL_EXPORTER_OTLP_HEADERS=X-OTLP-Shared-Token=${{shared.OTLP_SHARED_TOKEN}}
9+
WL_BROWSER_OTLP_TRACES_TARGET=https://otel.cloud.gascityhall.com/v1/traces
10+
WL_BROWSER_OTLP_HEADERS=X-OTLP-Shared-Token=${{shared.OTLP_SHARED_TOKEN}}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ web/dist/*
99
web/coverage/
1010
web/*.tsbuildinfo
1111
docs/production-checklist.md
12+
__pycache__/

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ LDFLAGS := -X main.version=$(VERSION) \
2424
-X main.date=$(BUILD_TIME) \
2525
-X main.inferEnabled=$(INFER_ENABLED)
2626

27-
.PHONY: build build-go web check check-all lint fmt-check fmt vet test test-integration test-integration-offline test-cover cover install install-tools setup clean web-check web-test audit audit-web
27+
.PHONY: build build-go web check check-all lint fmt-check fmt vet test test-integration test-integration-offline test-cover cover install install-tools setup clean web-check web-test audit audit-web railway-sync-vars test-scripts
2828

2929
## web: build web UI (requires bun)
3030
web:
@@ -123,6 +123,14 @@ audit:
123123
audit-web:
124124
cd web && bun audit
125125

126+
## railway-sync-vars: preview Railway OTLP env sync from .env.production.example
127+
railway-sync-vars:
128+
python3 scripts/railway_sync_vars.py --env-file .env.production.example --service wasteland --environment production --shared-env-var OTLP_SHARED_TOKEN --dry-run
129+
130+
## test-scripts: run repository script unit tests
131+
test-scripts:
132+
python3 -m unittest discover -s scripts -p 'test_*.py'
133+
126134
## help: show this help
127135
help:
128136
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## //' | column -t -s ':'

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,30 @@ browser and server telemetry can share the same ingress token. Use
543543
`WL_BROWSER_OTLP_TRACES_TARGET` or `WL_BROWSER_OTLP_HEADERS` only when the
544544
browser path needs different routing from the server exporters.
545545

546+
For Railway, the repo now includes a root
547+
[`.env.production.example`](./.env.production.example) template that Railway
548+
can suggest/import into the linked service. It keeps the real token out of git
549+
by referencing a shared Railway variable:
550+
551+
```bash
552+
OTEL_EXPORTER_OTLP_HEADERS=X-OTLP-Shared-Token=${{shared.OTLP_SHARED_TOKEN}}
553+
WL_BROWSER_OTLP_HEADERS=X-OTLP-Shared-Token=${{shared.OTLP_SHARED_TOKEN}}
554+
```
555+
556+
Sync it into Railway with the Railway GraphQL API. Export the live shared OTLP
557+
token locally first, then run:
558+
559+
```bash
560+
export OTLP_SHARED_TOKEN=<shared-token>
561+
python3 scripts/railway_sync_vars.py --service wasteland --environment production --shared-env-var OTLP_SHARED_TOKEN --dry-run
562+
python3 scripts/railway_sync_vars.py --service wasteland --environment production --shared-env-var OTLP_SHARED_TOKEN --no-skip-deploys
563+
```
564+
565+
The sync script uses `RAILWAY_TOKEN` or `RAILWAY_API_TOKEN`, auto-discovers the
566+
project when the token has a single match, and upserts both the shared Railway
567+
variable and the service-scoped OTLP variables. By default it stages changes
568+
with `skipDeploys`; pass `--no-skip-deploys` to roll them out immediately.
569+
546570
## Development
547571

548572
```bash

internal/observability/otel_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package observability
33
import (
44
"context"
55
"errors"
6+
"io"
67
"net/http"
8+
"net/http/httptest"
79
"testing"
10+
"time"
811

912
"go.opentelemetry.io/otel"
1013
"go.opentelemetry.io/otel/propagation"
@@ -143,3 +146,93 @@ func TestInit_RestoresProvidersWhenMetricExporterInitFails(t *testing.T) {
143146
t.Fatal("Init() did not restore the original propagator")
144147
}
145148
}
149+
150+
func TestInit_ExportsTracesAndMetricsToConfiguredOTLPEndpoints(t *testing.T) {
151+
originalTracerProvider := otel.GetTracerProvider()
152+
originalMeterProvider := otel.GetMeterProvider()
153+
originalPropagator := otel.GetTextMapPropagator()
154+
t.Cleanup(func() {
155+
otel.SetTextMapPropagator(originalPropagator)
156+
otel.SetTracerProvider(originalTracerProvider)
157+
otel.SetMeterProvider(originalMeterProvider)
158+
})
159+
160+
type requestRecord struct {
161+
path string
162+
sharedToken string
163+
bodyLen int
164+
}
165+
166+
requests := make(chan requestRecord, 8)
167+
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168+
body, err := io.ReadAll(r.Body)
169+
if err != nil {
170+
t.Fatalf("read collector body: %v", err)
171+
}
172+
requests <- requestRecord{
173+
path: r.URL.Path,
174+
sharedToken: r.Header.Get("X-OTLP-Shared-Token"),
175+
bodyLen: len(body),
176+
}
177+
w.WriteHeader(http.StatusAccepted)
178+
}))
179+
defer collector.Close()
180+
181+
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", collector.URL+"/v1/traces")
182+
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", collector.URL+"/v1/metrics")
183+
t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "X-OTLP-Shared-Token=abc123TOKEN")
184+
185+
shutdown, enabled, err := Init(context.Background(), Config{
186+
ServiceName: "wasteland-hosted",
187+
ServiceNamespace: "wasteland",
188+
ServiceVersion: "test",
189+
Environment: "test",
190+
})
191+
if err != nil {
192+
t.Fatalf("Init() error = %v", err)
193+
}
194+
if !enabled {
195+
t.Fatal("Init() enabled = false, want true")
196+
}
197+
198+
tracer := otel.Tracer("test")
199+
_, span := tracer.Start(context.Background(), "wasteland.otlp.proof")
200+
span.End()
201+
202+
meter := otel.Meter("test")
203+
counter, err := meter.Int64Counter("wasteland_otlp_proof_total")
204+
if err != nil {
205+
t.Fatalf("Int64Counter() error = %v", err)
206+
}
207+
counter.Add(context.Background(), 1)
208+
209+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
210+
defer cancel()
211+
if err := shutdown(shutdownCtx); err != nil {
212+
t.Fatalf("shutdown() error = %v", err)
213+
}
214+
215+
seenPaths := map[string]bool{}
216+
timeout := time.After(5 * time.Second)
217+
for len(seenPaths) < 2 {
218+
select {
219+
case req := <-requests:
220+
if req.sharedToken != "abc123TOKEN" {
221+
t.Fatalf("collector shared token = %q, want %q", req.sharedToken, "abc123TOKEN")
222+
}
223+
if req.bodyLen == 0 {
224+
t.Fatalf("collector body for %s was empty", req.path)
225+
}
226+
seenPaths[req.path] = true
227+
case <-timeout:
228+
t.Fatalf("timed out waiting for OTLP exports, saw %v", seenPaths)
229+
}
230+
}
231+
232+
if !seenPaths["/v1/traces"] {
233+
t.Fatal("expected OTLP trace export to hit /v1/traces")
234+
}
235+
if !seenPaths["/v1/metrics"] {
236+
t.Fatal("expected OTLP metrics export to hit /v1/metrics")
237+
}
238+
}

0 commit comments

Comments
 (0)