Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

feat(metrics): add Prometheus instrumentation and a /metrics endpoint

Instrument the three things an operator needs to see and serve them on a
dedicated listener (-metrics-bind, default :9090; empty disables):

- s3fs: count + latency per S3 API operation (GetObject, PutObject, …) via a
process-level observer set with s3fs.SetMetricsObserver, keeping s3fs free of
any prometheus import.
- git ops: count + latency + in-flight gauge per protocol+service+status,
wrapped once per transport handler (http/git/ssh).
- auth: count + latency per transport+operation+decision, routed through a new
(*daemon).authorize chokepoint that all three transports now call.

Extras: push-hook runs (ok/error/timeout) with latency, repos auto-created
counter, and the default registry's Go-runtime + process collectors.

Per the operator's call there is no repo label (unbounded cardinality); git
ops are keyed by protocol+service+status only.

All vectors live in internal/metrics via promauto on the default registry,
exposed by promhttp.Handler under the existing errgroup Serve/Shutdown idiom.

Xe Iaso (May 29, 2026, 12:52 AM EDT) a300ea49 53aed857

+575 -12
+31
CLAUDE.md
··· 12 12 13 13 All three funnel authorization through one pluggable `internal/auth.Authorizer` (see [The auth seam](#the-auth-seam-internalauth)). 14 14 15 + A fourth listener serves **Prometheus metrics** at `/metrics` (`-metrics-bind`, default `:9090`, empty disables) — see [Metrics](#metrics-internalmetrics). 16 + 15 17 Module path: `tangled.org/xeiaso.net/objgit`. Go 1.26. 16 18 17 19 ## Commands ··· 75 77 implementation today is `auth.AllowAnonymous{AllowWrite}`: read for everyone, 76 78 write only when set — wired in `main.go` as `AllowAnonymous{AllowWrite: *allowPush}`, 77 79 so `-allow-push` is now just this default's config rather than a field on `daemon`. 80 + 81 + ### Metrics (`internal/metrics`) 82 + 83 + `internal/metrics` defines every Prometheus vector via `promauto` against the 84 + **default registry**, so client_golang's Go-runtime and process collectors are 85 + exported alongside them. `main.go` serves `promhttp.Handler()` on its own 86 + listener (`-metrics-bind`, default `:9090`) under the same errgroup Serve/Shutdown 87 + idiom as the HTTP transport. All series are prefixed `objgit_`. Per the operator's 88 + call there is **no `repo` label** (unbounded cardinality); git operations are 89 + keyed by `protocol`+`service`+`status` only. 90 + 91 + The package exposes thin helpers so call sites carry no label plumbing: 92 + `ObserveS3` (the s3fs observer), `ObserveGitOp`, `TrackInFlight` (returns a 93 + deferred decrement), `ObserveAuth` (maps the `auth` enums to labels), `ObserveHook`, 94 + and `ReposCreated`. Three instrumentation seams feed them: 95 + 96 + - **s3fs** reports each S3 round-trip at the API-call level (GetObject, PutObject, 97 + …). s3fs stays prometheus-free: it holds a process-level `func(op, dur, err)` 98 + observer set via `s3fs.SetMetricsObserver`, which `main` wires to 99 + `metrics.ObserveS3`. (It is a package-level setter, not an instance Option, 100 + because the S3 calls live in standalone constructors that don't hold the `S3FS`.) 101 + - **auth** routes through one chokepoint, `(*daemon).authorize` in `git_protocol.go`, 102 + which times `d.authz.Authorize` and records the decision before returning it — 103 + all three transports call it instead of `d.authz` directly. 104 + - **repo ops** are wrapped once per transport handler (`handleRPC`, `handle`, 105 + `handleSSH`): an in-flight gauge plus a duration/count keyed by protocol+service. 106 + HTTP rolls an authorization denial into the `error` git status (the precise 107 + denial is still visible in `objgit_auth_requests_total`); git:// and SSH record 108 + `denied` directly. 78 109 79 110 ### Git over SSH (`ssh.go`) 80 111
+30 -1
cmd/objgitd/git_protocol.go
··· 21 21 "github.com/go-git/go-git/v6/storage" 22 22 "github.com/go-git/go-git/v6/storage/filesystem" 23 23 "tangled.org/xeiaso.net/objgit/internal/auth" 24 + "tangled.org/xeiaso.net/objgit/internal/metrics" 24 25 ) 25 26 26 27 // handshakeTimeout bounds how long a client has to send its git-proto-request. ··· 50 51 return auth.Write 51 52 } 52 53 return auth.Read 54 + } 55 + 56 + // authorize is the single seam every transport routes authorization through: it 57 + // times the underlying Authorizer and records the decision (by transport, 58 + // operation, and outcome) before returning it. Each transport still renders the 59 + // Decision in its own dialect. 60 + func (d *daemon) authorize(ctx context.Context, req auth.Request) auth.Decision { 61 + start := time.Now() 62 + dec := d.authz.Authorize(ctx, req) 63 + metrics.ObserveAuth(req.Transport, req.Operation, dec, start) 64 + return dec 53 65 } 54 66 55 67 // daemon serves the git:// (TCP) protocol out of a billy filesystem. ··· 120 132 // writer is the raw conn: its final Close() ends the connection. 121 133 r := io.NopCloser(conn) 122 134 123 - if d.authz.Authorize(ctx, auth.Request{ 135 + defer metrics.TrackInFlight("git")() 136 + start := time.Now() 137 + 138 + if d.authorize(ctx, auth.Request{ 124 139 Repo: req.Pathname, 125 140 Operation: operationFor(req.RequestCommand), 126 141 Cred: auth.Anonymous{}, 127 142 Transport: "git", 128 143 }) != auth.Allow { 144 + metrics.ObserveGitOp("git", req.RequestCommand, "denied", start) 129 145 _, _ = pktline.WriteError(conn, fmt.Errorf("access denied")) 130 146 return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand) 131 147 } 132 148 149 + err := d.serveGit(ctx, conn, r, req, gitProtocol) 150 + status := "ok" 151 + if err != nil { 152 + status = "error" 153 + } 154 + metrics.ObserveGitOp("git", req.RequestCommand, status, start) 155 + return err 156 + } 157 + 158 + // serveGit dispatches a parsed, authorized git:// request to the matching 159 + // go-git transport command. 160 + func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, gitProtocol string) error { 133 161 switch req.RequestCommand { 134 162 case transport.UploadPackService: 135 163 st, err := d.loader.Load(&url.URL{Path: req.Pathname}) ··· 187 215 return nil, fmt.Errorf("init bare repo: %w", err) 188 216 } 189 217 218 + metrics.ReposCreated() 190 219 slog.Info("created repository", "path", repoPath) 191 220 return st, nil 192 221 }
+18
cmd/objgitd/hooks.go
··· 7 7 "io" 8 8 "log/slog" 9 9 "strings" 10 + "time" 10 11 11 12 "github.com/go-git/go-billy/v6" 12 13 "github.com/go-git/go-billy/v6/memfs" ··· 20 21 "tangled.org/xeiaso.net/kefka/command/registry" 21 22 "tangled.org/xeiaso.net/kefka/command/registry/coreutils" 22 23 "tangled.org/xeiaso.net/objgit/internal/kefkash" 24 + "tangled.org/xeiaso.net/objgit/internal/metrics" 23 25 "tangled.org/xeiaso.net/objgit/internal/mountfs" 24 26 "tangled.org/xeiaso.net/objgit/internal/treefs" 25 27 ) ··· 222 224 } 223 225 224 226 log.Info("hook: running") 227 + runStart := time.Now() 225 228 runErr := sh.Run(ctx, prog) 229 + metrics.ObserveHook(hookStatus(ctx, runErr), time.Since(runStart)) 226 230 227 231 var exit interp.ExitStatus 228 232 isExit := errors.As(runErr, &exit) ··· 240 244 } 241 245 log.Info("hook: finished", attrs...) 242 246 } 247 + 248 + // hookStatus classifies a hook run for metrics: "timeout" when the hook's 249 + // deadline fired, "error" for any other failure (including a non-zero exit), and 250 + // "ok" otherwise. 251 + func hookStatus(ctx context.Context, runErr error) string { 252 + switch { 253 + case ctx.Err() != nil: 254 + return "timeout" 255 + case runErr != nil: 256 + return "error" 257 + default: 258 + return "ok" 259 + } 260 + }
+13 -1
cmd/objgitd/http.go
··· 9 9 "net/http" 10 10 "net/url" 11 11 "strings" 12 + "time" 12 13 13 14 "github.com/go-git/go-git/v6/plumbing/transport" 14 15 "github.com/go-git/go-git/v6/storage" 15 16 "github.com/go-git/go-git/v6/utils/ioutil" 16 17 "tangled.org/xeiaso.net/objgit/internal/auth" 18 + "tangled.org/xeiaso.net/objgit/internal/metrics" 17 19 ) 18 20 19 21 // ServeHTTP speaks the git smart-HTTP protocol. It dispatches on the URL suffix ··· 87 89 // handleRPC serves a stateless negotiation round: 88 90 // POST /{repo}/git-(upload|receive)-pack. 89 91 func (d *daemon) handleRPC(w http.ResponseWriter, r *http.Request, service, repoPath string) { 92 + defer metrics.TrackInFlight("http")() 93 + start := time.Now() 94 + 90 95 st, ok := d.resolve(w, r, service, repoPath) 91 96 if !ok { 97 + // resolve has already written the HTTP error; a denied authorization is 98 + // recorded by d.authorize in auth_requests_total. Count the failed op 99 + // here so request totals stay consistent across transports. 100 + metrics.ObserveGitOp("http", service, "error", start) 92 101 return 93 102 } 94 103 ··· 138 147 GitProtocol: gitProtocol, 139 148 }) 140 149 } 150 + status := "ok" 141 151 if err != nil { 142 152 // The status line is already sent, so this can only be logged. 143 153 slog.Error("smart-http rpc failed", "service", service, "path", repoPath, "err", err) 154 + status = "error" 144 155 } 156 + metrics.ObserveGitOp("http", service, status, start) 145 157 } 146 158 147 159 // resolve loads the storer for an HTTP request, authorizing via the daemon's 148 160 // Authorizer before touching the repository. It writes an HTTP error and 149 161 // returns ok=false when the request cannot proceed. 150 162 func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) { 151 - switch d.authz.Authorize(r.Context(), auth.Request{ 163 + switch d.authorize(r.Context(), auth.Request{ 152 164 Repo: repoPath, 153 165 Operation: operationFor(service), 154 166 Cred: credFromRequest(r),
+36 -6
cmd/objgitd/main.go
··· 16 16 "github.com/facebookgo/flagenv" 17 17 "github.com/gliderlabs/ssh" 18 18 "github.com/go-git/go-git/v6/plumbing/transport" 19 + "github.com/prometheus/client_golang/prometheus/promhttp" 19 20 "github.com/tigrisdata/storage-go" 20 21 "golang.org/x/sync/errgroup" 21 22 "tangled.org/xeiaso.net/objgit/internal" 22 23 "tangled.org/xeiaso.net/objgit/internal/auth" 24 + "tangled.org/xeiaso.net/objgit/internal/metrics" 23 25 "tangled.org/xeiaso.net/objgit/internal/s3fs" 24 26 25 27 _ "github.com/joho/godotenv/autoload" 26 28 ) 27 29 28 30 var ( 29 - gitBind = flag.String("git-bind", ":9418", "TCP address to listen on for the git:// protocol; empty disables it") 30 - httpBind = flag.String("http-bind", ":8080", "TCP address to listen on for the git smart-HTTP protocol; empty disables it") 31 - sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it") 32 - bucket = flag.String("bucket", "", "Tigris bucket that holds the git repositories") 33 - allowPush = flag.Bool("allow-push", false, "allow unauthenticated git-receive-pack (push) requests") 34 - slogLevel = flag.String("slog-level", "INFO", "log level (DEBUG, INFO, WARN, ERROR)") 31 + gitBind = flag.String("git-bind", ":9418", "TCP address to listen on for the git:// protocol; empty disables it") 32 + httpBind = flag.String("http-bind", ":8080", "TCP address to listen on for the git smart-HTTP protocol; empty disables it") 33 + sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it") 34 + metricsBind = flag.String("metrics-bind", ":9090", "TCP address to serve the Prometheus /metrics endpoint; empty disables it") 35 + bucket = flag.String("bucket", "", "Tigris bucket that holds the git repositories") 36 + allowPush = flag.Bool("allow-push", false, "allow unauthenticated git-receive-pack (push) requests") 37 + slogLevel = flag.String("slog-level", "INFO", "log level (DEBUG, INFO, WARN, ERROR)") 35 38 36 39 allowHooks = flag.Bool("allow-hooks", false, "run .objgit/hooks/receive-pack in a sandbox after a successful push") 37 40 hookTimeout = flag.Duration("hook-timeout", 60*time.Second, "wall-clock limit for a single hook run") ··· 61 64 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 62 65 defer cancel() 63 66 67 + // Route s3fs S3 round-trips into Prometheus before any filesystem use. 68 + s3fs.SetMetricsObserver(metrics.ObserveS3) 69 + 64 70 client, err := storage.New(ctx) 65 71 if err != nil { 66 72 slog.Error("can't create Tigris storage client", "err", err) ··· 85 91 "git_bind", *gitBind, 86 92 "http_bind", *httpBind, 87 93 "ssh_bind", *sshBind, 94 + "metrics_bind", *metricsBind, 88 95 "bucket", *bucket, 89 96 "allow_push", *allowPush, 90 97 "allow_hooks", *allowHooks, 91 98 ) 92 99 93 100 g, gCtx := errgroup.WithContext(ctx) 101 + 102 + if *metricsBind != "" { 103 + ln, err := net.Listen("tcp", *metricsBind) 104 + if err != nil { 105 + slog.Error("can't listen", "metrics_bind", *metricsBind, "err", err) 106 + os.Exit(1) 107 + } 108 + mux := http.NewServeMux() 109 + mux.Handle("/metrics", promhttp.Handler()) 110 + srv := &http.Server{Handler: mux} 111 + g.Go(func() error { 112 + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { 113 + return err 114 + } 115 + return nil 116 + }) 117 + g.Go(func() error { 118 + <-gCtx.Done() 119 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 120 + defer cancel() 121 + return srv.Shutdown(shutdownCtx) 122 + }) 123 + } 94 124 95 125 if *gitBind != "" { 96 126 ln, err := net.Listen("tcp", *gitBind)
+28 -4
cmd/objgitd/ssh.go
··· 12 12 "os" 13 13 "path/filepath" 14 14 "strings" 15 + "time" 15 16 16 17 ssh "github.com/gliderlabs/ssh" 17 18 "github.com/go-git/go-billy/v6" ··· 19 20 "github.com/go-git/go-git/v6/utils/ioutil" 20 21 gossh "golang.org/x/crypto/ssh" 21 22 "tangled.org/xeiaso.net/objgit/internal/auth" 23 + "tangled.org/xeiaso.net/objgit/internal/metrics" 22 24 ) 23 25 24 26 const hostKeyPath = ".objgit/ssh_host_ed25519_key" ··· 141 143 if key := s.PublicKey(); key != nil { 142 144 cred = auth.PublicKey{Key: key} 143 145 } 144 - if d.authz.Authorize(s.Context(), auth.Request{ 146 + 147 + defer metrics.TrackInFlight("ssh")() 148 + start := time.Now() 149 + 150 + if d.authorize(s.Context(), auth.Request{ 145 151 Repo: repoPath, 146 152 Operation: operationFor(service), 147 153 Cred: cred, 148 154 Transport: "ssh", 149 155 }) != auth.Allow { 156 + metrics.ObserveGitOp("ssh", service, "denied", start) 150 157 fmt.Fprintln(s.Stderr(), "objgitd: access denied") 151 158 _ = s.Exit(1) 152 159 return ··· 158 165 "remote", s.RemoteAddr().String(), 159 166 ) 160 167 168 + status := "ok" 169 + if err := d.serveSSH(s, service, repoPath); err != nil { 170 + status = "error" 171 + } 172 + metrics.ObserveGitOp("ssh", service, status, start) 173 + } 174 + 175 + // serveSSH dispatches an authorized git-over-SSH request to the matching go-git 176 + // transport command. It returns an error for metric classification; when a 177 + // repository cannot be opened it also writes a client-facing message and sets a 178 + // non-zero exit status, matching git's behavior. A mid-transfer error is logged 179 + // (the exit status is left to the session default, as before). 180 + func (d *daemon) serveSSH(s ssh.Session, service, repoPath string) error { 161 181 // SSH is a persistent stream like git://: the transport commands call Close 162 182 // between negotiation rounds, which would tear down the channel, so wrap the 163 183 // session in no-op closers. ··· 173 193 if err != nil { 174 194 fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) 175 195 _ = s.Exit(1) 176 - return 196 + return fmt.Errorf("loading %q: %w", repoPath, err) 177 197 } 178 198 if err := transport.UploadPack(ctx, st, r, w, &transport.UploadPackRequest{}); err != nil { 179 199 slog.Error("ssh upload-pack failed", "path", repoPath, "err", err) 200 + return err 180 201 } 181 202 182 203 case transport.UploadArchiveService: ··· 184 205 if err != nil { 185 206 fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) 186 207 _ = s.Exit(1) 187 - return 208 + return fmt.Errorf("loading %q: %w", repoPath, err) 188 209 } 189 210 if err := transport.UploadArchive(ctx, st, r, w, &transport.UploadArchiveRequest{}); err != nil { 190 211 slog.Error("ssh upload-archive failed", "path", repoPath, "err", err) 212 + return err 191 213 } 192 214 193 215 case transport.ReceivePackService: ··· 195 217 if err != nil { 196 218 fmt.Fprintf(s.Stderr(), "objgitd: cannot open repository %q\n", repoPath) 197 219 _ = s.Exit(1) 198 - return 220 + return fmt.Errorf("opening %q for push: %w", repoPath, err) 199 221 } 200 222 // streamingStorer hides PackfileWriter (the io.CopyBuffer-until-EOF path 201 223 // deadlocks on a live socket); d.receivePack runs push hooks afterward. 202 224 if err := d.receivePack(ctx, streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil { 203 225 slog.Error("ssh receive-pack failed", "path", repoPath, "err", err) 226 + return err 204 227 } 205 228 } 229 + return nil 206 230 }
+170
docs/plans/prometheus-metrics.md
··· 1 + # Plan: Prometheus metrics for objgitd 2 + 3 + ## Context 4 + 5 + `objgitd` currently emits only `slog` logs; an operator has no quantitative view 6 + of what the server is doing. This adds Prometheus instrumentation across the 7 + three things the user named — **S3/Tigris ops**, **git repo operations**, and 8 + **authorization decisions** — plus a few extra operator-visibility metrics, and 9 + serves them on a dedicated `/metrics` HTTP listener. 10 + 11 + Metrics use `github.com/prometheus/client_golang/prometheus/promauto` against the 12 + **default registry**, so Go-runtime and process collectors come for free and 13 + `promhttp.Handler()` exposes everything. 14 + 15 + ### Decisions locked in with the user 16 + 17 + - **No `repo` label.** Unbounded cardinality. Repo operations are labeled by 18 + `protocol` + `service` + `status` only (the original "per repo" ask is dropped). 19 + - **`/metrics` on by default at `:9090`** (`-metrics-bind`, empty string disables). 20 + - **s3fs measured at the S3-API-call level** (GetObject, PutObject, …), not the 21 + billy-method level. 22 + - **Extras:** push-hook runs, repos auto-created, in-flight requests gauge, 23 + Go/process collectors. 24 + 25 + ## New package: `internal/metrics/metrics.go` 26 + 27 + Central definitions so names stay consistent and packages don't each import 28 + promauto. All vectors via `promauto.NewCounterVec`/`NewHistogramVec`/`NewGauge` 29 + (default registerer). Prefix `objgit_`. 30 + 31 + | Metric | Type | Labels | 32 + | -------------------------------------- | --------- | ---------------------------------------------------------------------------------------- | 33 + | `objgit_s3_requests_total` | counter | `operation`, `status` (`ok`/`error`) | 34 + | `objgit_s3_request_duration_seconds` | histogram | `operation` | 35 + | `objgit_git_requests_total` | counter | `protocol`, `service`, `status` (`ok`/`error`/`denied`) | 36 + | `objgit_git_request_duration_seconds` | histogram | `protocol`, `service` | 37 + | `objgit_git_requests_in_flight` | gauge | `protocol` | 38 + | `objgit_auth_requests_total` | counter | `transport`, `operation` (`read`/`write`), `decision` (`allow`/`deny`/`unauthenticated`) | 39 + | `objgit_auth_request_duration_seconds` | histogram | `transport` | 40 + | `objgit_hook_runs_total` | counter | `status` (`ok`/`error`/`timeout`) | 41 + | `objgit_hook_run_duration_seconds` | histogram | (none) | 42 + | `objgit_repos_created_total` | counter | (none) | 43 + 44 + Exported helpers (keep call sites tiny): 45 + 46 + - `ObserveS3(operation string, dur time.Duration, err error)` — the s3fs observer. 47 + - `ObserveGitOp(protocol, service, status string, start time.Time)`. 48 + - `TrackInFlight(protocol string) func()` — inc gauge, return a dec closure for `defer`. 49 + - `ObserveAuth(transport string, op auth.Operation, d auth.Decision, start time.Time)` 50 + (maps the enums to label strings here so transports stay clean). 51 + - `ObserveHook(status string, dur time.Duration)` and `ReposCreated()`. 52 + 53 + ## Instrumentation seams 54 + 55 + ### 1. s3fs — S3-API granularity, kept dependency-free 56 + 57 + The S3 calls live in standalone constructors (`newS3ReadFile`, etc.) that hold a 58 + `*storage.Client` but not the `S3FS`, so an instance `Option` can't reach them 59 + without threading a field through every constructor. Instead use a process-level 60 + observer (`internal/s3fs/metrics.go`) — honest, since the S3 client and the 61 + Prometheus registry it feeds are both process-global: 62 + 63 + ```go 64 + func SetMetricsObserver(fn func(operation string, dur time.Duration, err error)) 65 + func observeS3(operation string, start time.Time, err error) // package-private helper 66 + ``` 67 + 68 + `SetMetricsObserver` installs the observer once at startup; `observeS3` times one 69 + call and reports it (no-op when no observer is set). Wrap each `client.*` call 70 + site with a `start := time.Now()` / `observeS3("Op", start, err)` pair. 71 + Representative sites (from exploration): 72 + 73 + - `file.go` — HeadObject (`:68`), GetObject (`:77`), PutObject (`:249`), 74 + CreateMultipartUpload (`:304`), UploadPart (`:342`), CompleteMultipartUpload (`:394`). 75 + - `basic.go` — HeadObject (`:137`), PutObject (`:186`), RenameObject (`:201`), 76 + ListObjectsV2 (`:97`,`:158`), DeleteObject (`:226`). 77 + - `dir.go` — ListObjectsV2 (`:32`), PutObject (`:83`). 78 + 79 + `operation` label = the S3 API name (string constant per call site). Keeps s3fs 80 + free of any prometheus import — the observer is an opaque callback the project 81 + wires in `main.go` via `s3fs.SetMetricsObserver(metrics.ObserveS3)`. 82 + 83 + ### 2. Auth — collapse three call sites into one daemon chokepoint 84 + 85 + Today each transport calls `d.authz.Authorize(...)` directly 86 + (`ssh.go:144`, `git_protocol.go:123`, `http.go:151`). Add: 87 + 88 + ```go 89 + func (d *daemon) authorize(ctx context.Context, req auth.Request) auth.Decision 90 + ``` 91 + 92 + in `git_protocol.go` (next to `operationFor`, `git_protocol.go:48`). It times the 93 + inner `d.authz.Authorize`, records `ObserveAuth(...)`, and returns the decision. 94 + Replace all three call sites to call `d.authorize(...)`. This is the single auth 95 + seam the metrics hook lives on. 96 + 97 + ### 3. Repo operations — one wrap per transport handler 98 + 99 + Each handler knows `(protocol, service)` early. Pattern at each entry point: 100 + 101 + ```go 102 + done := metrics.TrackInFlight(protocol); defer done() 103 + start := time.Now() 104 + // ... handle; determine status ... 105 + metrics.ObserveGitOp(protocol, service, status, start) 106 + ``` 107 + 108 + - **HTTP** (`http.go`): wrap in `handleRPC`/`resolve` (`http.go:89`,`:150`), `protocol="http"`. 109 + - **git://** (`git_protocol.go`): in `handle` after `req.Decode` (`git_protocol.go:109`), `protocol="git"`. 110 + - **SSH** (`ssh.go`): in `handleSSH` after `gitServiceFor` (`ssh.go:130`), `protocol="ssh"`. 111 + 112 + `service` = `transport.UploadPackService` / `ReceivePackService` / 113 + `UploadArchiveService`. `status` = `denied` when `authorize` rejects, `error` on a 114 + non-nil handler error, else `ok`. 115 + 116 + ### 4. Extras 117 + 118 + - **Push hooks** (`hooks.go`, `runHooks` ~`:117`): time each hook run, classify 119 + `timeout` (context deadline) vs `error` vs `ok`, call `metrics.ObserveHook`. 120 + - **Repos auto-created** (`git_protocol.go` `loadOrInit`, the create branch ~`:185`, 121 + right by the existing `slog.Info("created repository", …)`): `metrics.ReposCreated()`. 122 + - **In-flight gauge** — already handled by `TrackInFlight` in seam #3. 123 + - **Go/process collectors** — automatic via default registry; no code beyond serving. 124 + 125 + ### 5. main.go — flag, wiring, metrics server 126 + 127 + - Add flag after `hookTimeout` (`main.go:37`): 128 + `metricsBind = flag.String("metrics-bind", ":9090", "TCP address for the Prometheus /metrics endpoint; empty disables it")`. 129 + `flagenv` auto-maps it to `METRICS_BIND`. 130 + - Wire the s3fs observer before constructing the filesystem: 131 + `s3fs.SetMetricsObserver(metrics.ObserveS3)` (`main.go`, just before `storage.New`). 132 + - Add `"metrics_bind", *metricsBind` to the startup `slog.Info` (`main.go:84`). 133 + - Add a metrics listener in the errgroup, cloning the existing HTTP 134 + Serve/Shutdown idiom (`main.go:104-123`): 135 + ```go 136 + if *metricsBind != "" { 137 + ln, err := net.Listen("tcp", *metricsBind) // os.Exit(1) on error, like the others 138 + mux := http.NewServeMux(); mux.Handle("/metrics", promhttp.Handler()) 139 + srv := &http.Server{Handler: mux} 140 + g.Go(func() error { /* srv.Serve, ignore http.ErrServerClosed */ }) 141 + g.Go(func() error { /* <-gCtx.Done(); 10s Shutdown */ }) 142 + } 143 + ``` 144 + 145 + ## Dependencies 146 + 147 + `github.com/prometheus/client_golang` is already in `go.sum` (indirect). After 148 + coding, run `go mod tidy` to promote it to a direct require and pull 149 + `promauto`/`promhttp`. No other new deps. 150 + 151 + ## Files touched 152 + 153 + - **New:** `internal/metrics/metrics.go` 154 + - `internal/s3fs/filesystem.go` (Option + `observe` helper), `internal/s3fs/file.go`, `basic.go`, `dir.go` (wrap call sites) 155 + - `cmd/objgitd/git_protocol.go` (`authorize` helper, `handle` wrap, `loadOrInit` counter) 156 + - `cmd/objgitd/http.go`, `cmd/objgitd/ssh.go` (auth call-site swap + repo-op wrap) 157 + - `cmd/objgitd/hooks.go` (hook metrics) 158 + - `cmd/objgitd/main.go` (flag, s3fs wiring, metrics server, startup log) 159 + - `go.mod` / `go.sum` (tidy) 160 + - `CLAUDE.md` (document the metrics surface + `-metrics-bind`, brief) 161 + 162 + ## Verification 163 + 164 + 1. `go build ./...` and `go vet ./...`. 165 + 2. `go test ./...` — existing protocol/SSH tests must still pass (metrics calls are 166 + additive and the observer is nil-safe when unset). The metric helpers 167 + themselves are not unit-tested. 168 + 3. Manual: `./objgitd -bucket $BUCKET -allow-push` then 169 + `curl -s localhost:9090/metrics | grep objgit_` after a clone/push; confirm 170 + s3, git, auth, hook, and repos-created series plus Go/process collectors.
+9
go.mod
··· 11 11 github.com/go-git/go-billy/v6 v6.0.0-alpha.1 12 12 github.com/go-git/go-git/v6 v6.0.0-alpha.4 13 13 github.com/joho/godotenv v1.5.1 14 + github.com/prometheus/client_golang v1.23.2 14 15 github.com/tigrisdata/storage-go v0.6.0 15 16 go.uber.org/atomic v1.11.0 16 17 golang.org/x/crypto v0.51.0 ··· 38 39 github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect 39 40 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect 40 41 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect 42 + github.com/beorn7/perks v1.0.1 // indirect 43 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 41 44 github.com/cloudflare/circl v1.6.3 // indirect 42 45 github.com/emirpasic/gods v1.18.1 // indirect 43 46 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect ··· 46 49 github.com/go-git/gcfg/v2 v2.0.2 // indirect 47 50 github.com/kevinburke/ssh_config v1.6.0 // indirect 48 51 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 52 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 49 53 github.com/pborman/getopt/v2 v2.1.0 // indirect 50 54 github.com/pjbgf/sha1cd v0.6.0 // indirect 51 55 github.com/pmezard/go-difflib v1.0.0 // indirect 56 + github.com/prometheus/client_model v0.6.2 // indirect 57 + github.com/prometheus/common v0.66.1 // indirect 58 + github.com/prometheus/procfs v0.16.1 // indirect 52 59 github.com/sergi/go-diff v1.4.0 // indirect 60 + go.yaml.in/yaml/v2 v2.4.2 // indirect 53 61 golang.org/x/net v0.54.0 // indirect 54 62 golang.org/x/sys v0.44.0 // indirect 55 63 golang.org/x/term v0.43.0 // indirect 56 64 golang.org/x/text v0.37.0 // indirect 65 + google.golang.org/protobuf v1.36.8 // indirect 57 66 )
+26
go.sum
··· 42 42 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= 43 43 github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= 44 44 github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 45 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 46 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 47 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 48 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 45 49 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= 46 50 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 47 51 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= ··· 77 81 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 78 82 github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= 79 83 github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 84 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 85 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 80 86 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 81 87 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 82 88 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= ··· 86 92 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 87 93 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 88 94 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 95 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 96 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 97 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 98 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 89 99 github.com/pborman/getopt/v2 v2.1.0 h1:eNfR+r+dWLdWmV8g5OlpyrTYHkhVNxHBdN2cCrJmOEA= 90 100 github.com/pborman/getopt/v2 v2.1.0/go.mod h1:4NtW75ny4eBw9fO1bhtNdYTlZKYX5/tBLtsOpwKIKd0= 91 101 github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= 92 102 github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 93 103 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 94 104 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 105 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 106 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 107 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 108 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 109 + github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 110 + github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 111 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 112 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 95 113 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 96 114 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 97 115 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= ··· 104 122 github.com/tigrisdata/storage-go v0.6.0/go.mod h1:l3u7N9LDIhv4lfpkEBJYzolWJ/SBb6WiBexgy/uq6iQ= 105 123 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 106 124 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 125 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 126 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 127 + go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 128 + go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 107 129 golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= 108 130 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 109 131 golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= ··· 116 138 golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 117 139 golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 118 140 golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 141 + google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 142 + google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 119 143 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 120 144 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 146 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 121 147 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 122 148 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 123 149 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+157
internal/metrics/metrics.go
··· 1 + // Package metrics defines objgitd's Prometheus instrumentation. Every metric 2 + // registers against the default registry via promauto, so the Go-runtime and 3 + // process collectors that client_golang installs there are exported alongside 4 + // them by promhttp.Handler. 5 + // 6 + // The exported helpers keep call sites in the transports and s3fs tiny and free 7 + // of label plumbing: each transport reports a git operation, each Authorize 8 + // call reports a decision, and s3fs reports an S3 round-trip through the 9 + // observer signature ObserveS3 satisfies. 10 + package metrics 11 + 12 + import ( 13 + "time" 14 + 15 + "github.com/prometheus/client_golang/prometheus" 16 + "github.com/prometheus/client_golang/prometheus/promauto" 17 + "tangled.org/xeiaso.net/objgit/internal/auth" 18 + ) 19 + 20 + const namespace = "objgit" 21 + 22 + var ( 23 + s3Requests = promauto.NewCounterVec(prometheus.CounterOpts{ 24 + Namespace: namespace, 25 + Subsystem: "s3", 26 + Name: "requests_total", 27 + Help: "Total S3/Tigris API calls by operation and outcome.", 28 + }, []string{"operation", "status"}) 29 + 30 + s3Duration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 31 + Namespace: namespace, 32 + Subsystem: "s3", 33 + Name: "request_duration_seconds", 34 + Help: "Latency of S3/Tigris API calls by operation.", 35 + Buckets: prometheus.DefBuckets, 36 + }, []string{"operation"}) 37 + 38 + gitRequests = promauto.NewCounterVec(prometheus.CounterOpts{ 39 + Namespace: namespace, 40 + Subsystem: "git", 41 + Name: "requests_total", 42 + Help: "Total git operations by protocol, service, and outcome.", 43 + }, []string{"protocol", "service", "status"}) 44 + 45 + gitDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 46 + Namespace: namespace, 47 + Subsystem: "git", 48 + Name: "request_duration_seconds", 49 + Help: "Latency of git operations by protocol and service.", 50 + Buckets: prometheus.DefBuckets, 51 + }, []string{"protocol", "service"}) 52 + 53 + gitInFlight = promauto.NewGaugeVec(prometheus.GaugeOpts{ 54 + Namespace: namespace, 55 + Subsystem: "git", 56 + Name: "requests_in_flight", 57 + Help: "Git operations currently being served, by protocol.", 58 + }, []string{"protocol"}) 59 + 60 + authRequests = promauto.NewCounterVec(prometheus.CounterOpts{ 61 + Namespace: namespace, 62 + Subsystem: "auth", 63 + Name: "requests_total", 64 + Help: "Authorization decisions by transport, operation, and decision.", 65 + }, []string{"transport", "operation", "decision"}) 66 + 67 + authDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 68 + Namespace: namespace, 69 + Subsystem: "auth", 70 + Name: "request_duration_seconds", 71 + Help: "Latency of authorization decisions by transport.", 72 + Buckets: prometheus.DefBuckets, 73 + }, []string{"transport"}) 74 + 75 + hookRuns = promauto.NewCounterVec(prometheus.CounterOpts{ 76 + Namespace: namespace, 77 + Subsystem: "hook", 78 + Name: "runs_total", 79 + Help: "Push-hook executions by outcome (ok, error, timeout).", 80 + }, []string{"status"}) 81 + 82 + hookDuration = promauto.NewHistogram(prometheus.HistogramOpts{ 83 + Namespace: namespace, 84 + Subsystem: "hook", 85 + Name: "run_duration_seconds", 86 + Help: "Latency of push-hook executions.", 87 + Buckets: prometheus.DefBuckets, 88 + }) 89 + 90 + reposCreated = promauto.NewCounter(prometheus.CounterOpts{ 91 + Namespace: namespace, 92 + Name: "repos_created_total", 93 + Help: "Repositories auto-created on first push.", 94 + }) 95 + ) 96 + 97 + // ObserveS3 records one S3/Tigris API call. Its signature matches the observer 98 + // internal/s3fs expects, so main wires it via s3fs.SetMetricsObserver. 99 + func ObserveS3(operation string, dur time.Duration, err error) { 100 + status := "ok" 101 + if err != nil { 102 + status = "error" 103 + } 104 + s3Requests.WithLabelValues(operation, status).Inc() 105 + s3Duration.WithLabelValues(operation).Observe(dur.Seconds()) 106 + } 107 + 108 + // TrackInFlight increments the in-flight gauge for protocol and returns a 109 + // closure that decrements it; call the result with defer. 110 + func TrackInFlight(protocol string) func() { 111 + gitInFlight.WithLabelValues(protocol).Inc() 112 + return func() { gitInFlight.WithLabelValues(protocol).Dec() } 113 + } 114 + 115 + // ObserveGitOp records a completed git operation: status is "ok", "error", or 116 + // "denied". start is when the handler began serving it. 117 + func ObserveGitOp(protocol, service, status string, start time.Time) { 118 + gitRequests.WithLabelValues(protocol, service, status).Inc() 119 + gitDuration.WithLabelValues(protocol, service).Observe(time.Since(start).Seconds()) 120 + } 121 + 122 + // ObserveAuth records an authorization decision, mapping the auth enums to 123 + // stable label strings so transports stay free of label plumbing. 124 + func ObserveAuth(transport string, op auth.Operation, d auth.Decision, start time.Time) { 125 + authRequests.WithLabelValues(transport, operationLabel(op), decisionLabel(d)).Inc() 126 + authDuration.WithLabelValues(transport).Observe(time.Since(start).Seconds()) 127 + } 128 + 129 + func operationLabel(op auth.Operation) string { 130 + if op == auth.Write { 131 + return "write" 132 + } 133 + return "read" 134 + } 135 + 136 + func decisionLabel(d auth.Decision) string { 137 + switch d { 138 + case auth.Allow: 139 + return "allow" 140 + case auth.Unauthenticated: 141 + return "unauthenticated" 142 + default: 143 + return "deny" 144 + } 145 + } 146 + 147 + // ObserveHook records a push-hook execution and its latency. status is "ok", 148 + // "error", or "timeout". 149 + func ObserveHook(status string, dur time.Duration) { 150 + hookRuns.WithLabelValues(status).Inc() 151 + hookDuration.Observe(dur.Seconds()) 152 + } 153 + 154 + // ReposCreated counts a repository auto-created on first push. 155 + func ReposCreated() { 156 + reposCreated.Inc() 157 + }
+12
internal/s3fs/basic.go
··· 94 94 ctx := context.TODO() 95 95 prefix := key + "/" 96 96 maxKeys := int32(1) 97 + start := time.Now() 97 98 list, lerr := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 98 99 Bucket: &fs3.bucket, 99 100 Prefix: &prefix, 100 101 Delimiter: &fs3.separator, 101 102 MaxKeys: &maxKeys, 102 103 }) 104 + observeS3("ListObjectsV2", start, lerr) 103 105 if lerr != nil { 104 106 return nil, lerr 105 107 } ··· 134 136 135 137 ctx := context.TODO() 136 138 139 + start := time.Now() 137 140 head, err := fs3.client.HeadObject(ctx, &s3.HeadObjectInput{ 138 141 Bucket: &fs3.bucket, 139 142 Key: &key, 140 143 }) 144 + observeS3("HeadObject", start, err) 141 145 if err == nil { 142 146 return newFileInfoFromHead(path.Base(key), head, fs3.unixMeta), nil 143 147 } ··· 155 159 156 160 prefix := key + "/" 157 161 maxKeys := int32(1) 162 + start = time.Now() 158 163 list, lerr := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 159 164 Bucket: &fs3.bucket, 160 165 Prefix: &prefix, 161 166 Delimiter: &fs3.separator, 162 167 MaxKeys: &maxKeys, 163 168 }) 169 + observeS3("ListObjectsV2", start, lerr) 164 170 if lerr != nil { 165 171 return nil, lerr 166 172 } ··· 183 189 184 190 if buf, ok := fs3.detachTemp(oldpath); ok { 185 191 data := buf.snapshot() 192 + start := time.Now() 186 193 _, err := fs3.client.PutObject(ctx, &s3.PutObjectInput{ 187 194 Bucket: &fs3.bucket, 188 195 Key: &dst, 189 196 Body: bytes.NewReader(data), 190 197 }) 198 + observeS3("PutObject", start, err) 191 199 if err != nil { 192 200 return fmt.Errorf("failed to upload temp %q to %q: %w", oldpath, newpath, err) 193 201 } ··· 198 206 // so we don't need a separate CopyObject + DeleteObject. CopySource is 199 207 // bucket-qualified; Key is the destination key. 200 208 copySource := fs3.bucket + "/" + src 209 + start := time.Now() 201 210 _, err := fs3.client.RenameObject(ctx, &s3.CopyObjectInput{ 202 211 Bucket: &fs3.bucket, 203 212 CopySource: &copySource, 204 213 Key: &dst, 205 214 }) 215 + observeS3("RenameObject", start, err) 206 216 if err != nil { 207 217 return fmt.Errorf("failed to rename %q to %q: %w", oldpath, newpath, err) 208 218 } ··· 223 233 224 234 // Send the request 225 235 // TODO: Parse the response? 236 + start := time.Now() 226 237 _, err := fs3.client.DeleteObject(ctx, &s3.DeleteObjectInput{ 227 238 Bucket: &fs3.bucket, 228 239 Key: &key, 229 240 }) 241 + observeS3("DeleteObject", start, err) 230 242 if err != nil { 231 243 return fmt.Errorf("failed to remove file: %w", err) 232 244 }
+5
internal/s3fs/dir.go
··· 9 9 "os" 10 10 pathpkg "path" 11 11 "strings" 12 + "time" 12 13 13 14 "github.com/aws/aws-sdk-go-v2/aws" 14 15 "github.com/aws/aws-sdk-go-v2/service/s3" ··· 29 30 var dirs []fs.DirEntry 30 31 var files []fs.DirEntry 31 32 for { 33 + start := time.Now() 32 34 res, err := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 33 35 Bucket: &fs3.bucket, 34 36 Prefix: &prefix, 35 37 ContinuationToken: ct, 36 38 Delimiter: &fs3.separator, 37 39 }) 40 + observeS3("ListObjectsV2", start, err) 38 41 if err != nil { 39 42 return nil, err 40 43 } ··· 80 83 // perm are used for all directories that MkdirAll creates. If path is/ 81 84 // already a directory, MkdirAll does nothing and returns nil. 82 85 func (fs3 *S3FS) MkdirAll(filename string, perm os.FileMode) error { 86 + start := time.Now() 83 87 _, err := fs3.client.PutObject(context.TODO(), &s3.PutObjectInput{ 84 88 Bucket: new(fs3.bucket), 85 89 Key: new(filename), 86 90 Body: bytes.NewBuffer(nil), 87 91 }) 92 + observeS3("PutObject", start, err) 88 93 89 94 return err 90 95 }
+14
internal/s3fs/file.go
··· 65 65 // Create the context 66 66 ctx := context.TODO() // TODO: How can user-supplied contexts be supported? 67 67 68 + start := time.Now() 68 69 ho, err := client.HeadObject(ctx, &s3.HeadObjectInput{ 69 70 Bucket: new(bucket), 70 71 Key: new(key), 71 72 }) 73 + observeS3("HeadObject", start, err) 72 74 if err != nil { 73 75 return nil, &os.PathError{Op: "read", Path: key, Err: err} 74 76 } 75 77 76 78 // Run the GetObject operation 79 + start = time.Now() 77 80 res, err := client.GetObject(ctx, &s3.GetObjectInput{ 78 81 Bucket: &bucket, 79 82 Key: &key, 80 83 }) 84 + observeS3("GetObject", start, err) 81 85 if err != nil { 82 86 return nil, fmt.Errorf("unable to perform GetObject operation: %w", err) 83 87 } ··· 246 250 247 251 // Run the GetObject operation 248 252 // TODO: Currently `res` is not used. Should it be? 253 + start := time.Now() 249 254 _, err := f.client.PutObject(ctx, &s3.PutObjectInput{ 250 255 Bucket: &f.bucket, 251 256 Key: &f.key, 252 257 Body: body, 253 258 Metadata: newFileMetadata(f.unixMeta), 254 259 }) 260 + observeS3("PutObject", start, err) 255 261 if err != nil { 256 262 return fmt.Errorf("unable to perform GetObject operation: %w", err) 257 263 } ··· 301 307 302 308 // Run the GetObject operation. POSIX attributes (if enabled) must be set 303 309 // now: CompleteMultipartUpload cannot attach user metadata. 310 + start := time.Now() 304 311 res, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ 305 312 Bucket: &bucket, 306 313 Key: &key, 307 314 Metadata: newFileMetadata(cfg), 308 315 }) 316 + observeS3("CreateMultipartUpload", start, err) 309 317 if err != nil { 310 318 return nil, fmt.Errorf("unable to create multipart upload: %w", err) 311 319 } ··· 339 347 pn := f.uploadN.Load() 340 348 341 349 // Run the UploadPart operation 350 + start := time.Now() 342 351 _, err = f.client.UploadPart(ctx, &s3.UploadPartInput{ 343 352 Bucket: &f.bucket, 344 353 Key: &f.key, ··· 346 355 PartNumber: new(pn), 347 356 Body: r, 348 357 }) 358 + observeS3("UploadPart", start, err) 349 359 if err != nil { 350 360 return 0, fmt.Errorf("unable to upload part %d: %w", pn, err) 351 361 } ··· 391 401 392 402 // Complete the multipart upload 393 403 // TODO: Currently `res` is not used. Should it be? 404 + start := time.Now() 394 405 _, err := f.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ 395 406 Bucket: &f.bucket, 396 407 Key: &f.key, 397 408 UploadId: &f.uploadID, 398 409 }) 410 + observeS3("CompleteMultipartUpload", start, err) 399 411 if err != nil { 400 412 return fmt.Errorf("unable to complete multipart upload: %w", err) 401 413 } ··· 446 458 func (f *s3DirFile) Truncate(size int64) error { return f.eisdir("truncate") } 447 459 448 460 func (f *s3DirFile) Stat() (fs.FileInfo, error) { 461 + start := time.Now() 449 462 ho, err := f.cli.HeadObject(context.Background(), &s3.HeadObjectInput{ 450 463 Bucket: new(f.bucket), 451 464 Key: new(f.name), 452 465 }) 466 + observeS3("HeadObject", start, err) 453 467 if err != nil { 454 468 return nil, err 455 469 }
+26
internal/s3fs/metrics.go
··· 1 + package s3fs 2 + 3 + import "time" 4 + 5 + // metricsObserver, when non-nil, is invoked after every S3 API round-trip with 6 + // the operation name, its wall-clock duration, and any error. It is 7 + // process-global because the S3 client and the Prometheus registry it feeds 8 + // both are; SetMetricsObserver wires it from main. This keeps s3fs free of any 9 + // Prometheus import — the observer is an opaque callback. 10 + var metricsObserver func(operation string, dur time.Duration, err error) 11 + 12 + // SetMetricsObserver installs a process-wide observer invoked after each S3 API 13 + // call. Pass nil to disable. Call it during startup, before the filesystem is 14 + // in use; it is not safe to change concurrently with active operations. 15 + func SetMetricsObserver(fn func(operation string, dur time.Duration, err error)) { 16 + metricsObserver = fn 17 + } 18 + 19 + // observeS3 reports one S3 API call to the metrics observer if one is 20 + // installed. operation is the S3 API name (e.g. "GetObject"); start is taken 21 + // immediately before the call. 22 + func observeS3(operation string, start time.Time, err error) { 23 + if metricsObserver != nil { 24 + metricsObserver(operation, time.Since(start), err) 25 + } 26 + }