Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ The proxy can be configured via:
-database-url string PostgreSQL connection URL
-log-level string Log level: debug, info, warn, error (default "info")
-log-format string Log format: text, json (default "text")
-access-log string Path to the JSONL access log
-version Print version and exit
```

Expand All @@ -438,6 +439,7 @@ PROXY_DATABASE_PATH=./cache/proxy.db
PROXY_DATABASE_URL=postgres://user:pass@localhost/proxy?sslmode=disable
PROXY_LOG_LEVEL=info
PROXY_LOG_FORMAT=text
PROXY_ACCESS_LOG_PATH=/var/log/proxy/access.jsonl
```

### Configuration File
Expand All @@ -458,6 +460,9 @@ log:
level: "info"
format: "text"

access_log:
path: "/var/log/proxy/access.jsonl" # Optional JSONL activity log

# Optional: override upstream URLs
upstream:
npm: "https://registry.npmjs.org"
Expand Down
8 changes: 8 additions & 0 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
// Log level: debug, info, warn, error (default "info")
// -log-format string
// Log format: text, json (default "text")
// -access-log string
// Path to the JSONL access log (disabled by default)
//
// Stats Flags:
//
Expand Down Expand Up @@ -72,6 +74,7 @@
// PROXY_DATABASE_URL - PostgreSQL connection URL
// PROXY_LOG_LEVEL - Log level
// PROXY_LOG_FORMAT - Log format
// PROXY_ACCESS_LOG_PATH - JSONL access log path
// PROXY_UPSTREAM_MAVEN - Maven repository upstream URL
// PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL - Gradle Plugin Portal upstream URL
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads
Expand Down Expand Up @@ -184,6 +187,7 @@ func runServe() {
databaseURL := fs.String("database-url", "", "PostgreSQL connection URL")
logLevel := fs.String("log-level", "", "Log level: debug, info, warn, error")
logFormat := fs.String("log-format", "", "Log format: text, json")
accessLogPath := fs.String("access-log", "", "Path to the JSONL access log")
version := fs.Bool("version", false, "Print version and exit")

fs.Usage = func() {
Expand All @@ -201,6 +205,7 @@ func runServe() {
fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n")
fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\n")
fmt.Fprintf(os.Stderr, " PROXY_ACCESS_LOG_PATH JSONL access log path\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal upstream URL\n")
fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n")
Expand Down Expand Up @@ -256,6 +261,9 @@ func runServe() {
if *logFormat != "" {
cfg.Log.Format = *logFormat
}
if *accessLogPath != "" {
cfg.AccessLog.Path = *accessLogPath
}

// Validate configuration
if err := cfg.Validate(); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ log:
# Log format: "text" or "json"
format: "text"

# JSONL access log. Leave path empty to disable it.
access_log:
path: ""

# Upstream registry URLs and authentication
upstream:
# npm registry URL
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ log:
| `log.level` | `PROXY_LOG_LEVEL` | `-log-level` | `debug`, `info`, `warn`, `error` |
| `log.format` | `PROXY_LOG_FORMAT` | `-log-format` | `text`, `json` |

## Access Log

The optional access log records client requests and each HTTP exchange with an upstream registry. It is always written as JSONL, with one JSON object per line. Records for the same client request share a `request_id`.

```yaml
access_log:
path: "/var/log/proxy/access.jsonl"
```

| Config | Environment | Flag | Description |
|--------|-------------|------|-------------|
| `access_log.path` | `PROXY_ACCESS_LOG_PATH` | `-access-log` | File to append JSONL records to; empty disables the log |

The parent directory must exist and be writable when the proxy starts. A newly created log file is readable and writable only by the proxy process owner.

A request that receives a rate limit response from an upstream can produce records like these:

```json
{"time":"2026-08-16T12:00:00Z","event":"upstream","request_id":"host/example-000001","method":"GET","url":"https://registry.example/packages/example","status_code":429,"duration_ms":42}
{"time":"2026-08-16T12:00:00Z","event":"request","request_id":"host/example-000001","method":"GET","path":"/npm/example","status_code":502,"duration_ms":43,"remote_addr":"192.0.2.10:41234"}
```

Upstream retries and OCI authentication calls are separate `upstream` records, so the log preserves every status returned over the wire. Network failures have an `error` field and no `status_code`. URL credentials, query strings, and fragments are omitted from both upstream URLs and client paths.

## Upstream Registries

Override default upstream registry URLs:
Expand Down
109 changes: 109 additions & 0 deletions internal/accesslog/accesslog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Package accesslog writes proxy activity as JSON Lines.
package accesslog

import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"sync"
"time"
)

const (
accessLogFileMode os.FileMode = 0o600

// EventRequest identifies the response sent by the proxy to a client.
EventRequest = "request"
// EventUpstream identifies one HTTP exchange with an upstream service.
EventUpstream = "upstream"
)

type requestIDKey struct{}

// Entry is one proxy activity record.
type Entry struct {
Time time.Time `json:"time"`
Event string `json:"event"`
RequestID string `json:"request_id,omitempty"`
Method string `json:"method"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
StatusCode int `json:"status_code,omitempty"`
DurationMS int64 `json:"duration_ms"`
RemoteAddr string `json:"remote_addr,omitempty"`
Error string `json:"error,omitempty"`
}

// Logger appends complete JSON objects to a file, one per line.
type Logger struct {
mu sync.Mutex
file *os.File
encoder *json.Encoder
}

// Open opens path for append, creating it with owner-only permissions when needed.
func Open(path string) (*Logger, error) {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, accessLogFileMode)
if err != nil {
return nil, fmt.Errorf("opening access log: %w", err)
}

return &Logger{
file: file,
encoder: json.NewEncoder(file),
}, nil
}

// Write appends an entry to the log.
func (l *Logger) Write(entry Entry) error {
if entry.Time.IsZero() {
entry.Time = time.Now().UTC()
}

l.mu.Lock()
defer l.mu.Unlock()

if err := l.encoder.Encode(entry); err != nil {
return fmt.Errorf("writing access log: %w", err)
}
return nil
}

// Close closes the log file after any active writer finishes.
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()

if err := l.file.Close(); err != nil {
return fmt.Errorf("closing access log: %w", err)
}
return nil
}

// WithRequestID stores a proxy request ID in ctx.
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}

// RequestID returns the proxy request ID stored in ctx.
func RequestID(ctx context.Context) string {
requestID, _ := ctx.Value(requestIDKey{}).(string)
return requestID
}

// URLWithoutSecrets returns a URL without user information, query values, or fragments.
func URLWithoutSecrets(value *url.URL) string {
if value == nil {
return ""
}

clean := *value
clean.User = nil
clean.RawQuery = ""
clean.ForceQuery = false
clean.Fragment = ""
clean.RawFragment = ""
return clean.String()
}
91 changes: 91 additions & 0 deletions internal/accesslog/accesslog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package accesslog

import (
"bufio"
"context"
"encoding/json"
"net/url"
"os"
"path/filepath"
"sync"
"testing"
)

func TestLoggerWritesJSONLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "access.jsonl")
logger, err := Open(path)
if err != nil {
t.Fatal(err)
}

const entries = 20
var wg sync.WaitGroup
for range entries {
Comment thread
andrew marked this conversation as resolved.
wg.Add(1)
go func() {
defer wg.Done()
if err := logger.Write(Entry{
Event: EventUpstream,
RequestID: "request-id",
Method: "GET",
URL: "https://registry.example/packages/example",
StatusCode: 429,
}); err != nil {
t.Errorf("Write: %v", err)
}
}()
}
wg.Wait()

if err := logger.Close(); err != nil {
t.Fatal(err)
}

file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = file.Close() }()

scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
var entry Entry
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
t.Fatalf("line %d is not JSON: %v", count+1, err)
}
if entry.Time.IsZero() {
t.Errorf("line %d has no time", count+1)
}
if entry.StatusCode != 429 {
t.Errorf("line %d status_code = %d, want 429", count+1, entry.StatusCode)
}
count++
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if count != entries {
t.Errorf("lines = %d, want %d", count, entries)
}
}

func TestRequestID(t *testing.T) {
ctx := WithRequestID(context.Background(), "abc-123")
if got := RequestID(ctx); got != "abc-123" {
t.Errorf("RequestID = %q, want %q", got, "abc-123")
}
}

func TestURLWithoutSecrets(t *testing.T) {
value, err := url.Parse("https://user:password@registry.example/package.tgz?token=secret#fragment")
if err != nil {
t.Fatal(err)
}

got := URLWithoutSecrets(value)
want := "https://registry.example/package.tgz"
if got != want {
t.Errorf("URLWithoutSecrets = %q, want %q", got, want)
}
}
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ type Config struct {
// Log configures logging.
Log LogConfig `json:"log" yaml:"log"`

// AccessLog configures the JSONL activity log.
AccessLog AccessLogConfig `json:"access_log" yaml:"access_log"`

// Upstream configures upstream registry URLs (optional overrides).
Upstream UpstreamConfig `json:"upstream" yaml:"upstream"`

Expand Down Expand Up @@ -280,6 +283,12 @@ type LogConfig struct {
Format string `json:"format" yaml:"format"`
}

// AccessLogConfig configures the JSONL activity log.
type AccessLogConfig struct {
// Path is the file to append activity records to. Empty disables the access log.
Path string `json:"path" yaml:"path"`
}

// UpstreamConfig configures upstream registry URLs and authentication.
// Leave empty to use defaults.
type UpstreamConfig struct {
Expand Down Expand Up @@ -508,6 +517,7 @@ func setEnvBool(dst *bool, key string) {
// - PROXY_DATABASE_PATH
// - PROXY_LOG_LEVEL
// - PROXY_LOG_FORMAT
// - PROXY_ACCESS_LOG_PATH
// - PROXY_HEALTH_STORAGE_PROBE_INTERVAL
func (c *Config) LoadFromEnv() {
setEnvString(&c.Listen, "PROXY_LISTEN")
Expand All @@ -524,6 +534,7 @@ func (c *Config) LoadFromEnv() {
setEnvString(&c.Database.URL, "PROXY_DATABASE_URL")
setEnvString(&c.Log.Level, "PROXY_LOG_LEVEL")
setEnvString(&c.Log.Format, "PROXY_LOG_FORMAT")
setEnvString(&c.AccessLog.Path, "PROXY_ACCESS_LOG_PATH")
setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN")
setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL")
setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN")
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func TestDefault(t *testing.T) {
if cfg.Database.Path == "" {
t.Error("Database.Path should not be empty")
}
if cfg.AccessLog.Path != "" {
t.Errorf("AccessLog.Path = %q, want disabled by default", cfg.AccessLog.Path)
}
if cfg.Gradle.BuildCache.MaxUploadSize != "100MB" {
t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "100MB")
}
Expand Down Expand Up @@ -212,6 +215,8 @@ database:
log:
level: "debug"
format: "json"
access_log:
path: "/var/log/proxy/access.jsonl"
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing config file: %v", err)
Expand Down Expand Up @@ -240,6 +245,9 @@ log:
if cfg.Log.Format != "json" {
t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json")
}
if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" {
t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/var/log/proxy/access.jsonl")
}
}

func TestLoadJSON(t *testing.T) {
Expand Down Expand Up @@ -275,6 +283,7 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("PROXY_UI_URL", "https://ui.env.example.com/ui")
t.Setenv("PROXY_STORAGE_PATH", "/env/cache")
t.Setenv("PROXY_LOG_LEVEL", testLevelDebug)
t.Setenv("PROXY_ACCESS_LOG_PATH", "/tmp/proxy-access.jsonl")
t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public")
t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2")
t.Setenv("PROXY_UPSTREAM_DEBIAN", "http://archive.ubuntu.com/ubuntu")
Expand All @@ -301,6 +310,9 @@ func TestLoadFromEnv(t *testing.T) {
if cfg.Log.Level != testLevelDebug {
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug)
}
if cfg.AccessLog.Path != "/tmp/proxy-access.jsonl" {
t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/tmp/proxy-access.jsonl")
}
if cfg.Upstream.Maven != "https://maven.example.com/repository/maven-public" {
t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://maven.example.com/repository/maven-public")
}
Expand Down
Loading