[mirror] Scalable static site server for Git forges (like GitHub Pages)
1package git_pages
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "iter"
9 "log"
10 "log/slog"
11 "math/rand/v2"
12 "net/http"
13 "os"
14 "runtime/debug"
15 "strconv"
16 "sync"
17 "time"
18
19 slogmulti "github.com/samber/slog-multi"
20
21 syslog "codeberg.org/git-pages/go-slog-syslog"
22
23 "github.com/prometheus/client_golang/prometheus"
24 "github.com/prometheus/client_golang/prometheus/promauto"
25
26 "github.com/getsentry/sentry-go"
27 sentryhttp "github.com/getsentry/sentry-go/http"
28 sentryslog "github.com/getsentry/sentry-go/slog"
29)
30
31var (
32 httpRequestCount = promauto.NewCounterVec(prometheus.CounterOpts{
33 Name: "git_pages_http_request_count",
34 Help: "Count of HTTP requests by method and response status code",
35 }, []string{"method", "code"})
36
37 httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
38 Name: "git_pages_http_request_duration_seconds",
39 Help: "Time to respond to incoming HTTP requests",
40 Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
41
42 NativeHistogramBucketFactor: 1.1,
43 NativeHistogramMaxBucketNumber: 100,
44 NativeHistogramMinResetDuration: 10 * time.Minute,
45 }, []string{"method"})
46)
47
48var syslogHandler syslog.Handler
49
50func hasSentry() bool {
51 return os.Getenv("SENTRY_DSN") != ""
52}
53
54func chainSentryMiddleware(
55 middleware ...func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event,
56) func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
57 return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
58 for idx := 0; idx < len(middleware) && event != nil; idx++ {
59 event = middleware[idx](event, hint)
60 }
61 return event
62 }
63}
64
65// sensitiveHTTPHeaders extends the list of sensitive headers defined in the Sentry Go SDK with our
66// own application-specific header field names.
67var sensitiveHTTPHeaders = map[string]struct{}{
68 "Forge-Authorization": {},
69}
70
71// scrubSentryEvent removes sensitive HTTP header fields from the Sentry event.
72func scrubSentryEvent(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
73 if event.Request != nil && event.Request.Headers != nil {
74 for key := range event.Request.Headers {
75 if _, ok := sensitiveHTTPHeaders[key]; ok {
76 delete(event.Request.Headers, key)
77 }
78 }
79 }
80 return event
81}
82
83// sampleSentryEvent returns a function that discards a Sentry event according to the sample rate,
84// unless the associated HTTP request triggers a mutation or it took too long to produce a response,
85// in which case the event is never discarded.
86func sampleSentryEvent(sampleRate float64) func(*sentry.Event, *sentry.EventHint) *sentry.Event {
87 return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
88 newSampleRate := sampleRate
89 if event.Request != nil {
90 switch event.Request.Method {
91 case "PUT", "POST", "DELETE":
92 newSampleRate = 1
93 }
94 }
95 duration := event.Timestamp.Sub(event.StartTime)
96 threshold := time.Duration(config.Observability.SlowResponseThreshold)
97 if duration >= threshold {
98 newSampleRate = 1
99 }
100 if rand.Float64() < newSampleRate {
101 return event
102 }
103 return nil
104 }
105}
106
107func InitObservability() {
108 debug.SetPanicOnFault(true)
109
110 environment := "development"
111 if value, ok := os.LookupEnv("ENVIRONMENT"); ok {
112 environment = value
113 }
114
115 logHandlers := []slog.Handler{}
116
117 switch config.LogFormat {
118 case "none":
119 // nothing to do
120 case "text":
121 logHandlers = append(logHandlers,
122 slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{}))
123 case "json":
124 logHandlers = append(logHandlers,
125 slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{}))
126 default:
127 log.Println("unknown log format", config.LogFormat)
128 }
129
130 if syslogAddr := os.Getenv("SYSLOG_ADDR"); syslogAddr != "" {
131 var err error
132 syslogHandler, err = syslog.NewHandler(&syslog.HandlerOptions{
133 Address: syslogAddr,
134 AppName: "git-pages",
135 StructuredDataID: "git-pages",
136 })
137 if err != nil {
138 log.Fatalf("syslog: %v", err)
139 }
140 logHandlers = append(logHandlers, syslogHandler)
141 }
142
143 if hasSentry() {
144 enableLogs := false
145 if value, err := strconv.ParseBool(os.Getenv("SENTRY_LOGS")); err == nil {
146 enableLogs = value
147 }
148
149 enableTracing := false
150 if value, err := strconv.ParseBool(os.Getenv("SENTRY_TRACING")); err == nil {
151 enableTracing = value
152 }
153
154 tracesSampleRate := 1.00
155 switch environment {
156 case "development", "staging":
157 default:
158 tracesSampleRate = 0.05
159 }
160
161 options := sentry.ClientOptions{}
162 options.DisableTelemetryBuffer = !config.Feature("sentry-telemetry-buffer")
163 options.Environment = environment
164 options.EnableLogs = enableLogs
165 options.EnableTracing = enableTracing
166 options.TracesSampleRate = 1 // use our own custom sampling logic
167 options.BeforeSend = scrubSentryEvent
168 options.BeforeSendTransaction = chainSentryMiddleware(
169 sampleSentryEvent(tracesSampleRate),
170 scrubSentryEvent,
171 )
172 if err := sentry.Init(options); err != nil {
173 log.Fatalf("sentry: %s\n", err)
174 }
175
176 if enableLogs {
177 logHandlers = append(logHandlers, sentryslog.Option{
178 AddSource: true,
179 }.NewSentryHandler(context.Background()))
180 }
181 }
182
183 slog.SetDefault(slog.New(slogmulti.Fanout(logHandlers...)))
184}
185
186func FiniObservability() {
187 var wg sync.WaitGroup
188 timeout := 2 * time.Second
189 if syslogHandler != nil {
190 wg.Go(func() { syslogHandler.Flush(timeout) })
191 }
192 if hasSentry() {
193 wg.Go(func() { sentry.Flush(timeout) })
194 }
195 wg.Wait()
196}
197
198func ObserveError(err error) {
199 if errors.Is(err, context.Canceled) {
200 // Something has explicitly requested cancellation.
201 // Timeout results in a different error.
202 return
203 }
204
205 if hasSentry() {
206 sentry.CaptureException(err)
207 }
208}
209
210type observedResponseWriter struct {
211 inner http.ResponseWriter
212 status int
213}
214
215func newObservedResponseWriter(w http.ResponseWriter) observedResponseWriter {
216 return observedResponseWriter{
217 inner: w,
218 status: 0,
219 }
220}
221
222func (w *observedResponseWriter) Unwrap() http.ResponseWriter {
223 return w.inner
224}
225
226func (w *observedResponseWriter) Header() http.Header {
227 return w.inner.Header()
228}
229
230func (w *observedResponseWriter) Write(data []byte) (int, error) {
231 return w.inner.Write(data)
232}
233
234func (w *observedResponseWriter) WriteHeader(statusCode int) {
235 w.status = statusCode
236 w.inner.WriteHeader(statusCode)
237}
238
239func ObserveHTTPHandler(handler http.Handler) http.Handler {
240 if hasSentry() {
241 handler = func(next http.Handler) http.Handler {
242 next = sentryhttp.New(sentryhttp.Options{
243 Repanic: true,
244 }).Handle(handler)
245
246 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247 // Prevent the Sentry SDK from continuing traces as we don't use this feature.
248 r.Header.Del(sentry.SentryTraceHeader)
249 r.Header.Del(sentry.SentryBaggageHeader)
250
251 next.ServeHTTP(w, r)
252 })
253 }(handler)
254 }
255
256 handler = func(next http.Handler) http.Handler {
257 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258 ow := newObservedResponseWriter(w)
259
260 start := time.Now()
261 next.ServeHTTP(&ow, r)
262 duration := time.Since(start)
263
264 httpRequestCount.
265 With(prometheus.Labels{"method": r.Method, "code": fmt.Sprintf("%d", ow.status)}).
266 Inc()
267 httpRequestDurationSeconds.
268 With(prometheus.Labels{"method": r.Method}).
269 Observe(duration.Seconds())
270 })
271 }(handler)
272
273 return handler
274}
275
276type noopSpan struct{}
277
278func (span noopSpan) Finish() {}
279
280func ObserveFunction(
281 ctx context.Context, funcName string, data ...any,
282) (
283 interface{ Finish() }, context.Context,
284) {
285 switch {
286 case hasSentry():
287 span := sentry.StartSpan(ctx, "function")
288 span.Description = funcName
289 ObserveData(span.Context(), data...)
290 return span, span.Context()
291 default:
292 return noopSpan{}, ctx
293 }
294}
295
296func ObserveData(ctx context.Context, data ...any) {
297 if span := sentry.SpanFromContext(ctx); span != nil {
298 for i := 0; i < len(data); i += 2 {
299 name, value := data[i], data[i+1]
300 span.SetData(name.(string), value)
301 }
302 }
303}
304
305var (
306 blobsRetrievedCount = promauto.NewCounter(prometheus.CounterOpts{
307 Name: "git_pages_blobs_retrieved",
308 Help: "Count of blobs retrieved",
309 })
310 blobsRetrievedBytes = promauto.NewCounter(prometheus.CounterOpts{
311 Name: "git_pages_blobs_retrieved_bytes",
312 Help: "Total size in bytes of blobs retrieved",
313 })
314
315 blobsStoredCount = promauto.NewCounter(prometheus.CounterOpts{
316 Name: "git_pages_blobs_stored",
317 Help: "Count of blobs stored",
318 })
319 blobsStoredBytes = promauto.NewCounter(prometheus.CounterOpts{
320 Name: "git_pages_blobs_stored_bytes",
321 Help: "Total size in bytes of blobs stored",
322 })
323
324 manifestsRetrievedCount = promauto.NewCounter(prometheus.CounterOpts{
325 Name: "git_pages_manifests_retrieved",
326 Help: "Count of manifests retrieved",
327 })
328)
329
330type observedBackend struct {
331 inner Backend
332}
333
334var _ Backend = (*observedBackend)(nil)
335
336func NewObservedBackend(backend Backend) Backend {
337 return &observedBackend{inner: backend}
338}
339
340func (backend *observedBackend) HasFeature(ctx context.Context, feature BackendFeature) (isOn bool) {
341 span, ctx := ObserveFunction(ctx, "HasFeature")
342 isOn = backend.inner.HasFeature(ctx, feature)
343 span.Finish()
344 return
345}
346
347func (backend *observedBackend) EnableFeature(ctx context.Context, feature BackendFeature) (err error) {
348 span, ctx := ObserveFunction(ctx, "EnableFeature")
349 err = backend.inner.EnableFeature(ctx, feature)
350 span.Finish()
351 return
352}
353
354func (backend *observedBackend) GetBlob(
355 ctx context.Context, name string,
356) (
357 reader io.ReadSeeker, metadata BlobMetadata, err error,
358) {
359 span, ctx := ObserveFunction(ctx, "GetBlob", "blob.name", name)
360 if reader, metadata, err = backend.inner.GetBlob(ctx, name); err == nil {
361 ObserveData(ctx, "blob.size", metadata.Size)
362 blobsRetrievedCount.Inc()
363 blobsRetrievedBytes.Add(float64(metadata.Size))
364 }
365 span.Finish()
366 return
367}
368
369func (backend *observedBackend) PutBlob(ctx context.Context, name string, data []byte) (err error) {
370 span, ctx := ObserveFunction(ctx, "PutBlob", "blob.name", name, "blob.size", len(data))
371 if err = backend.inner.PutBlob(ctx, name, data); err == nil {
372 blobsStoredCount.Inc()
373 blobsStoredBytes.Add(float64(len(data)))
374 }
375 span.Finish()
376 return
377}
378
379func (backend *observedBackend) DeleteBlob(ctx context.Context, name string) (err error) {
380 span, ctx := ObserveFunction(ctx, "DeleteBlob", "blob.name", name)
381 err = backend.inner.DeleteBlob(ctx, name)
382 span.Finish()
383 return
384}
385
386func (backend *observedBackend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] {
387 return func(yield func(BlobMetadata, error) bool) {
388 span, ctx := ObserveFunction(ctx, "EnumerateBlobs")
389 for metadata, err := range backend.inner.EnumerateBlobs(ctx) {
390 if !yield(metadata, err) {
391 break
392 }
393 }
394 span.Finish()
395 }
396}
397
398func (backend *observedBackend) GetManifest(
399 ctx context.Context, name string, opts GetManifestOptions,
400) (
401 manifest *Manifest, metadata ManifestMetadata, err error,
402) {
403 span, ctx := ObserveFunction(ctx, "GetManifest",
404 "manifest.name", name,
405 "manifest.bypass_cache", opts.BypassCache,
406 )
407 if manifest, metadata, err = backend.inner.GetManifest(ctx, name, opts); err == nil {
408 manifestsRetrievedCount.Inc()
409 }
410 span.Finish()
411 return
412}
413
414func (backend *observedBackend) StageManifest(ctx context.Context, manifest *Manifest) (err error) {
415 span, ctx := ObserveFunction(ctx, "StageManifest")
416 err = backend.inner.StageManifest(ctx, manifest)
417 span.Finish()
418 return
419}
420
421func (backend *observedBackend) HasAtomicCAS(ctx context.Context) bool {
422 return backend.inner.HasAtomicCAS(ctx)
423}
424
425func (backend *observedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions) (err error) {
426 span, ctx := ObserveFunction(ctx, "CommitManifest", "manifest.name", name)
427 err = backend.inner.CommitManifest(ctx, name, manifest, opts)
428 span.Finish()
429 return
430}
431
432func (backend *observedBackend) DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) (err error) {
433 span, ctx := ObserveFunction(ctx, "DeleteManifest", "manifest.name", name)
434 err = backend.inner.DeleteManifest(ctx, name, opts)
435 span.Finish()
436 return
437}
438
439func (backend *observedBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] {
440 return func(yield func(ManifestMetadata, error) bool) {
441 span, ctx := ObserveFunction(ctx, "EnumerateManifests")
442 for metadata, err := range backend.inner.EnumerateManifests(ctx) {
443 if !yield(metadata, err) {
444 break
445 }
446 }
447 span.Finish()
448 }
449}
450
451func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) {
452 span, ctx := ObserveFunction(ctx, "CheckDomain", "domain.name", domain)
453 found, err = backend.inner.CheckDomain(ctx, domain)
454 span.Finish()
455 return
456}
457
458func (backend *observedBackend) CreateDomain(ctx context.Context, domain string) (err error) {
459 span, ctx := ObserveFunction(ctx, "CreateDomain", "domain.name", domain)
460 err = backend.inner.CreateDomain(ctx, domain)
461 span.Finish()
462 return
463}
464
465func (backend *observedBackend) FreezeDomain(ctx context.Context, domain string) (err error) {
466 span, ctx := ObserveFunction(ctx, "FreezeDomain", "domain.name", domain)
467 err = backend.inner.FreezeDomain(ctx, domain)
468 span.Finish()
469 return
470}
471
472func (backend *observedBackend) UnfreezeDomain(ctx context.Context, domain string) (err error) {
473 span, ctx := ObserveFunction(ctx, "UnfreezeDomain", "domain.name", domain)
474 err = backend.inner.UnfreezeDomain(ctx, domain)
475 span.Finish()
476 return
477}
478
479func (backend *observedBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) (err error) {
480 span, ctx := ObserveFunction(ctx, "AppendAuditLog", "audit.id", id)
481 err = backend.inner.AppendAuditLog(ctx, id, record)
482 span.Finish()
483 return
484}
485
486func (backend *observedBackend) QueryAuditLog(ctx context.Context, id AuditID) (record *AuditRecord, err error) {
487 span, ctx := ObserveFunction(ctx, "QueryAuditLog", "audit.id", id)
488 record, err = backend.inner.QueryAuditLog(ctx, id)
489 span.Finish()
490 return
491}
492
493func (backend *observedBackend) SearchAuditLog(
494 ctx context.Context, opts SearchAuditLogOptions,
495) iter.Seq2[AuditID, error] {
496 return func(yield func(AuditID, error) bool) {
497 span, ctx := ObserveFunction(ctx, "SearchAuditLog",
498 "audit.search.since", opts.Since,
499 "audit.search.until", opts.Until,
500 )
501 for id, err := range backend.inner.SearchAuditLog(ctx, opts) {
502 if !yield(id, err) {
503 break
504 }
505 }
506 span.Finish()
507 }
508}