···11+# ==============================================================================
22+# DEPRECATED: This file is deprecated. Use .env.example instead.
33+# This file will be removed in a future version.
44+# See .env.example for the unified configuration file.
55+# ==============================================================================
66+17# ATCR AppView Configuration
28# Copy this file to .env.appview and fill in your values
39# Load with: source .env.appview && ./bin/atcr-appview serve
···814915# HTTP listen address (default: :5000)
1016ATCR_HTTP_ADDR=:5000
1111-1212-# Debug listen address (default: :5001)
1313-# ATCR_DEBUG_ADDR=:5001
14171518# Base URL for the AppView service (REQUIRED for production)
1619# Used to generate OAuth redirect URIs and JWT realms
···6366# UI Configuration
6467# ==============================================================================
65686666-# Enable web UI (default: true)
6767-# Set to "false" to disable web interface and run registry-only
6868-ATCR_UI_ENABLED=true
6969-7069# SQLite database path for UI data (sessions, stars, pull counts, etc.)
7170# Default: /var/lib/atcr/ui.db
7271# ATCR_UI_DATABASE_PATH=/var/lib/atcr/ui.db
73727474-# Skip database migrations on startup (default: false)
7575-# Set to "true" to skip running migrations (useful for tests or fresh databases)
7676-# Production: Keep as "false" to ensure migrations are applied
7777-SKIP_DB_MIGRATIONS=false
7878-7973# ==============================================================================
8074# Logging Configuration
8175# ==============================================================================
···85798680# Log formatter: text, json (default: text)
8781# ATCR_LOG_FORMATTER=text
8282+8383+# ==============================================================================
8484+# Remote Log Shipping (optional)
8585+# ==============================================================================
8686+8787+# Backend: victoria, opensearch, loki (empty = disabled)
8888+# ATCR_LOG_SHIPPER_BACKEND=victoria
8989+9090+# Remote log service URL
9191+# ATCR_LOG_SHIPPER_URL=http://victorialogs:9428
9292+9393+# Number of logs to batch before flushing (default: 100)
9494+# ATCR_LOG_SHIPPER_BATCH_SIZE=100
9595+9696+# Max time between flushes (default: 5s)
9797+# ATCR_LOG_SHIPPER_FLUSH_INTERVAL=5s
9898+9999+# Basic auth credentials (optional)
100100+# ATCR_LOG_SHIPPER_USERNAME=
101101+# ATCR_LOG_SHIPPER_PASSWORD=
8810289103# ==============================================================================
90104# Hold Health Check Configuration
+293
.env.example
···11+# ==============================================================================
22+# ATCR Configuration
33+# ==============================================================================
44+# This file contains ALL configuration options for both AppView and Hold services.
55+# Copy to .env and uncomment/modify the values you need.
66+#
77+# QUICKSTART (minimum for local development):
88+# HOLD_PUBLIC_URL=http://127.0.0.1:8080
99+# ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080
1010+#
1111+# QUICKSTART (minimum for production):
1212+# APPVIEW_DOMAIN=atcr.io
1313+# HOLD_DOMAIN=hold01.atcr.io
1414+# HOLD_OWNER=did:plc:your-did
1515+# AWS_ACCESS_KEY_ID=xxx
1616+# AWS_SECRET_ACCESS_KEY=xxx
1717+# S3_BUCKET=xxx
1818+# S3_ENDPOINT=https://xxx
1919+#
2020+# ==============================================================================
2121+2222+# ==============================================================================
2323+# DOMAIN CONFIGURATION (Production)
2424+# ==============================================================================
2525+# These are used by docker-compose.prod.yml to derive other values automatically.
2626+# For local dev, skip these and set the explicit URLs below instead.
2727+2828+# Main AppView domain (registry API + web UI)
2929+# APPVIEW_DOMAIN=atcr.io
3030+3131+# Hold service domain
3232+# Used to derive: HOLD_PUBLIC_URL, ATCR_DEFAULT_HOLD_DID
3333+# HOLD_DOMAIN=hold01.atcr.io
3434+3535+# ==============================================================================
3636+# APPVIEW - SERVER CONFIGURATION
3737+# ==============================================================================
3838+3939+# HTTP listen address
4040+# Default: :5000
4141+# ATCR_HTTP_ADDR=:5000
4242+4343+# Public URL for OAuth redirect URIs and JWT realms
4444+# Development: Auto-detected from ATCR_HTTP_ADDR (e.g., http://127.0.0.1:5000)
4545+# Production: Set to your public URL (e.g., https://atcr.io)
4646+# ATCR_BASE_URL=https://atcr.io
4747+4848+# Service name for JWT issuer/service fields
4949+# Default: Derived from ATCR_BASE_URL hostname, or "atcr.io"
5050+# ATCR_SERVICE_NAME=atcr.io
5151+5252+# ==============================================================================
5353+# APPVIEW - STORAGE CONFIGURATION (REQUIRED)
5454+# ==============================================================================
5555+5656+# Default hold service DID for users without their own storage (REQUIRED)
5757+# Format: did:web:hostname[:port]
5858+# Docker dev: did:web:172.28.0.3:8080
5959+# Local dev: did:web:127.0.0.1:8080
6060+# Production: did:web:hold01.atcr.io
6161+ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080
6262+6363+# ==============================================================================
6464+# APPVIEW - AUTHENTICATION
6565+# ==============================================================================
6666+6767+# Path to JWT signing private key (auto-generated if missing)
6868+# Default: /var/lib/atcr/auth/private-key.pem
6969+# ATCR_AUTH_KEY_PATH=/var/lib/atcr/auth/private-key.pem
7070+7171+# Path to JWT signing certificate (auto-generated if missing)
7272+# Default: /var/lib/atcr/auth/private-key.crt
7373+# ATCR_AUTH_CERT_PATH=/var/lib/atcr/auth/private-key.crt
7474+7575+# JWT token expiration in seconds
7676+# Default: 300 (5 minutes)
7777+# ATCR_TOKEN_EXPIRATION=300
7878+7979+# Path to OAuth client P-256 signing key (auto-generated for production)
8080+# Used for confidential OAuth client authentication
8181+# Localhost deployments always use public OAuth clients (no key needed)
8282+# Default: /var/lib/atcr/oauth/client.key
8383+# ATCR_OAUTH_KEY_PATH=/var/lib/atcr/oauth/client.key
8484+8585+# OAuth client display name (shown in authorization screens)
8686+# Default: AT Container Registry
8787+# ATCR_CLIENT_NAME=AT Container Registry
8888+8989+# ==============================================================================
9090+# APPVIEW - WEB UI
9191+# ==============================================================================
9292+9393+# SQLite database path for UI data (sessions, stars, pull counts, etc.)
9494+# Default: /var/lib/atcr/ui.db
9595+# ATCR_UI_DATABASE_PATH=/var/lib/atcr/ui.db
9696+9797+# ==============================================================================
9898+# APPVIEW - JETSTREAM (ATProto Event Streaming)
9999+# ==============================================================================
100100+101101+# Jetstream WebSocket URL for real-time ATProto events
102102+# Default: wss://jetstream2.us-west.bsky.network/subscribe
103103+# JETSTREAM_URL=wss://jetstream2.us-west.bsky.network/subscribe
104104+105105+# Enable backfill worker to sync historical records
106106+# Default: true
107107+# ATCR_BACKFILL_ENABLED=true
108108+109109+# ATProto relay endpoint for backfill sync API
110110+# Default: https://relay1.us-east.bsky.network
111111+# ATCR_RELAY_ENDPOINT=https://relay1.us-east.bsky.network
112112+113113+# Backfill sync interval
114114+# Default: 1h
115115+# Examples: 30m, 1h, 2h, 24h
116116+# ATCR_BACKFILL_INTERVAL=1h
117117+118118+# ==============================================================================
119119+# APPVIEW - HEALTH CHECKS
120120+# ==============================================================================
121121+122122+# How often to check health of hold endpoints in the background
123123+# Default: 15m
124124+# ATCR_HEALTH_CHECK_INTERVAL=15m
125125+126126+# How long to cache health check results
127127+# Default: 15m
128128+# ATCR_HEALTH_CACHE_TTL=15m
129129+130130+# ==============================================================================
131131+# HOLD SERVICE - SERVER CONFIGURATION (REQUIRED)
132132+# ==============================================================================
133133+134134+# Public URL of hold service (REQUIRED)
135135+# The hostname becomes the hold name/record key
136136+# Local dev: http://127.0.0.1:8080
137137+# Production: https://hold01.atcr.io
138138+HOLD_PUBLIC_URL=http://127.0.0.1:8080
139139+140140+# HTTP listen address
141141+# Default: :8080
142142+# HOLD_SERVER_ADDR=:8080
143143+144144+# Allow public blob reads (pulls) without authentication
145145+# Writes (pushes) always require crew membership via PDS
146146+# Default: false
147147+# HOLD_PUBLIC=false
148148+149149+# ATProto relay endpoint for requesting crawl on startup
150150+# Makes the hold's embedded PDS discoverable by the relay network
151151+# Default: (empty - disabled)
152152+# Set to https://bsky.network to enable
153153+# HOLD_RELAY_ENDPOINT=https://bsky.network
154154+155155+# ==============================================================================
156156+# HOLD SERVICE - EMBEDDED PDS
157157+# ==============================================================================
158158+159159+# Directory path for embedded PDS carstore (SQLite database)
160160+# Default: /var/lib/atcr-hold
161161+# If empty, embedded PDS is disabled
162162+# Note: This is a directory path, NOT a file path
163163+# Carstore creates db.sqlite3 inside this directory
164164+HOLD_DATABASE_DIR=/var/lib/atcr-hold
165165+166166+# Path to signing key (auto-generated on first run if missing)
167167+# Default: {HOLD_DATABASE_DIR}/signing.key
168168+# HOLD_KEY_PATH=/var/lib/atcr-hold/signing.key
169169+170170+# ==============================================================================
171171+# HOLD SERVICE - REGISTRATION & ACCESS CONTROL
172172+# ==============================================================================
173173+174174+# Your ATProto DID (REQUIRED for registration)
175175+# Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social
176176+# On first run with HOLD_OWNER set:
177177+# 1. Hold service prints OAuth URL to logs
178178+# 2. Visit URL to authorize
179179+# 3. Hold creates captain + crew records
180180+# 4. Registration complete!
181181+# HOLD_OWNER=did:plc:your-did-here
182182+183183+# Allow any authenticated user to register as crew
184184+# Default: false (only explicit crew members can write)
185185+# Set to true for open/community holds
186186+# HOLD_ALLOW_ALL_CREW=false
187187+188188+# ==============================================================================
189189+# HOLD SERVICE - BLUESKY INTEGRATION
190190+# ==============================================================================
191191+192192+# Enable Bluesky posts when users push container images
193193+# When enabled, creates posts announcing image pushes
194194+# Default: false
195195+# HOLD_BLUESKY_POSTS_ENABLED=false
196196+197197+# Avatar image URL to download during bootstrap
198198+# HOLD_PROFILE_AVATAR=https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE
199199+200200+# ==============================================================================
201201+# HOLD SERVICE - ADMIN
202202+# ==============================================================================
203203+204204+# Enable admin panel
205205+# Default: false
206206+# HOLD_ADMIN_ENABLED=false
207207+208208+# ==============================================================================
209209+# STORAGE - S3 CONFIGURATION
210210+# ==============================================================================
211211+212212+# Storage driver type
213213+# Options: s3, filesystem
214214+# Default: s3
215215+STORAGE_DRIVER=s3
216216+217217+# S3 Access Credentials
218218+AWS_ACCESS_KEY_ID=your_access_key
219219+AWS_SECRET_ACCESS_KEY=your_secret_key
220220+221221+# S3 Region
222222+# For third-party S3 providers, this is ignored when S3_ENDPOINT is set,
223223+# but must be a valid AWS region to pass validation.
224224+# Default: us-east-1
225225+AWS_REGION=us-east-1
226226+227227+# S3 Bucket Name
228228+S3_BUCKET=atcr-blobs
229229+230230+# S3 Endpoint (for S3-compatible services)
231231+# Examples:
232232+# - Storj: https://gateway.storjshare.io
233233+# - UpCloud: https://[bucket-id].upcloudobjects.com
234234+# - Minio: http://minio:9000
235235+# Leave empty for AWS S3
236236+# S3_ENDPOINT=https://gateway.storjshare.io
237237+238238+# ==============================================================================
239239+# STORAGE - FILESYSTEM CONFIGURATION
240240+# ==============================================================================
241241+242242+# Root directory for filesystem storage (when STORAGE_DRIVER=filesystem)
243243+# Default: /var/lib/atcr/hold
244244+# STORAGE_ROOT_DIR=/var/lib/atcr/hold
245245+246246+# ==============================================================================
247247+# LOGGING (Shared by AppView and Hold)
248248+# ==============================================================================
249249+250250+# Log level: debug, info, warn, error
251251+# Default: info
252252+ATCR_LOG_LEVEL=info
253253+254254+# Log formatter: text, json
255255+# Default: text
256256+# ATCR_LOG_FORMATTER=text
257257+258258+# ==============================================================================
259259+# REMOTE LOG SHIPPING (Optional)
260260+# ==============================================================================
261261+262262+# Backend: victoria, opensearch, loki (empty = disabled)
263263+# ATCR_LOG_SHIPPER_BACKEND=victoria
264264+265265+# Remote log service URL
266266+# ATCR_LOG_SHIPPER_URL=http://victorialogs:9428
267267+268268+# Number of logs to batch before flushing
269269+# Default: 100
270270+# ATCR_LOG_SHIPPER_BATCH_SIZE=100
271271+272272+# Max time between flushes
273273+# Default: 5s
274274+# ATCR_LOG_SHIPPER_FLUSH_INTERVAL=5s
275275+276276+# Basic auth credentials (optional)
277277+# ATCR_LOG_SHIPPER_USERNAME=
278278+# ATCR_LOG_SHIPPER_PASSWORD=
279279+280280+# ==============================================================================
281281+# DEVELOPMENT / TESTING
282282+# ==============================================================================
283283+284284+# Enable test mode
285285+# - Uses HTTP for local DID resolution
286286+# - Adds transition:generic scope for OAuth
287287+# - Uses localhost for OAuth redirects while storing real URL in hold record
288288+# Default: false
289289+# TEST_MODE=false
290290+291291+# Disable presigned URLs (force proxy mode for testing)
292292+# Default: false
293293+# DISABLE_PRESIGNED_URLS=false
+26
.env.hold.example
···11+# ==============================================================================
22+# DEPRECATED: This file is deprecated. Use .env.example instead.
33+# This file will be removed in a future version.
44+# See .env.example for the unified configuration file.
55+# ==============================================================================
66+17# ATCR Hold Service Configuration
28# Copy this file to .env and fill in your values
39···125131126132# Log formatter: text, json (default: text)
127133# ATCR_LOG_FORMATTER=text
134134+135135+# ==============================================================================
136136+# Remote Log Shipping (optional)
137137+# ==============================================================================
138138+139139+# Backend: victoria, opensearch, loki (empty = disabled)
140140+# ATCR_LOG_SHIPPER_BACKEND=victoria
141141+142142+# Remote log service URL
143143+# ATCR_LOG_SHIPPER_URL=http://victorialogs:9428
144144+145145+# Number of logs to batch before flushing (default: 100)
146146+# ATCR_LOG_SHIPPER_BATCH_SIZE=100
147147+148148+# Max time between flushes (default: 5s)
149149+# ATCR_LOG_SHIPPER_FLUSH_INTERVAL=5s
150150+151151+# Basic auth credentials (optional)
152152+# ATCR_LOG_SHIPPER_USERNAME=
153153+# ATCR_LOG_SHIPPER_PASSWORD=
+3
.gitignore
···1212# Environment configuration
1313.env
14141515+# Docker-created quota config (actual config is in deploy/quotas.yaml)
1616+quotas.yaml
1717+1518# Generated assets (run go generate to rebuild)
1619pkg/appview/licenses/spdx-licenses.json
1720pkg/appview/static/js/htmx.min.js
···3535 os.Exit(1)
3636 }
37373838- // Initialize structured logging
3939- logging.InitLogger(cfg.LogLevel)
3838+ // Initialize structured logging with optional remote shipping
3939+ logging.InitLoggerWithShipper(cfg.LogLevel, logging.ShipperConfig{
4040+ Backend: cfg.LogShipper.Backend,
4141+ URL: cfg.LogShipper.URL,
4242+ BatchSize: cfg.LogShipper.BatchSize,
4343+ FlushInterval: cfg.LogShipper.FlushInterval,
4444+ Service: "hold",
4545+ Username: cfg.LogShipper.Username,
4646+ Password: cfg.LogShipper.Password,
4747+ })
40484149 // Initialize embedded PDS if database path is configured
4250 // This must happen before creating HoldService since service needs PDS for authorization
···234242 select {
235243 case err := <-serverErr:
236244 slog.Error("Server failed", "error", err)
245245+ logging.Shutdown() // Flush remaining logs
237246 os.Exit(1)
238247 case sig := <-sigChan:
239248 slog.Info("Received signal, shutting down gracefully", "signal", sig)
···275284 } else {
276285 slog.Info("Server shutdown complete")
277286 }
287287+288288+ // Flush any remaining logs before exit
289289+ logging.Shutdown()
278290 }
279291}
-13
deploy/.env.prod.template
···150150# Default: AT Container Registry
151151# ATCR_CLIENT_NAME=AT Container Registry
152152153153-# Enable web UI
154154-# Default: true
155155-ATCR_UI_ENABLED=true
156156-157157-# Skip database migrations on startup
158158-# Default: false (migrations are applied on startup)
159159-# Set to "true" only for testing or when migrations are managed externally
160160-# Production: Keep as "false" to ensure migrations are applied
161161-SKIP_DB_MIGRATIONS=false
162162-163153# ==============================================================================
164154# Logging Configuration
165155# ==============================================================================
···211201212202# Override service name (defaults to APPVIEW_DOMAIN)
213203# ATCR_SERVICE_NAME=atcr.io
214214-215215-# Debug listen address (optional - for pprof debugging)
216216-# ATCR_DEBUG_ADDR=:5001
217204218205# ==============================================================================
219206# CHECKLIST
···115115- **Description:** Service name used for JWT `service` and `issuer` fields. Controls token scope.
116116- **Example:** `atcr.io`, `registry.example.com`
117117118118-#### `ATCR_DEBUG_ADDR`
119119-- **Default:** `:5001`
120120-- **Description:** Debug listen address for pprof debugging endpoints
121121-- **Example:** `:5001`, `:6060`
122122-123118### Storage Configuration
124119125120#### `ATCR_DEFAULT_HOLD_DID` ⚠️ REQUIRED
···149144- **Recommendation:** Keep between 300-900 seconds (5-15 minutes)
150145151146### Web UI Configuration
152152-153153-#### `ATCR_UI_ENABLED`
154154-- **Default:** `true`
155155-- **Description:** Enable the web interface. Set to `false` to run registry API only (no web UI, no database).
156156-- **Use case:** API-only deployments where you don't need the browsing interface
157147158148#### `ATCR_UI_DATABASE_PATH`
159149- **Default:** `/var/lib/atcr/ui.db`
···245235# AppView config
246236ATCR_BASE_URL=https://registry.example.com
247237ATCR_DEFAULT_HOLD_DID=did:web:hold01.example.com
248248-ATCR_UI_ENABLED=true
249238ATCR_BACKFILL_ENABLED=true
250239251240# Hold config (linked hold service)
···261250# AppView config
262251ATCR_BASE_URL=https://registry.internal.example.com
263252ATCR_DEFAULT_HOLD_DID=did:web:hold.internal.example.com
264264-ATCR_UI_ENABLED=true
265253266254# Hold config (linked hold service)
267255HOLD_PUBLIC=false # Require auth for pulls
+48-47
pkg/appview/config.go
···1313 "net/url"
1414 "os"
1515 "strconv"
1616- "strings"
1716 "time"
18171918 "github.com/distribution/distribution/v3/configuration"
···2322type Config struct {
2423 Version string `yaml:"version"`
2524 LogLevel string `yaml:"log_level"`
2525+ LogShipper LogShipperConfig `yaml:"log_shipper"`
2626 Server ServerConfig `yaml:"server"`
2727 UI UIConfig `yaml:"ui"`
2828 Health HealthConfig `yaml:"health"`
···3030 Auth AuthConfig `yaml:"auth"`
3131 CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
3232 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
3333+}
3434+3535+// LogShipperConfig defines remote log shipping settings
3636+type LogShipperConfig struct {
3737+ // Backend selects the log shipping backend (from env: ATCR_LOG_SHIPPER_BACKEND)
3838+ // Valid values: "victoria", "opensearch", "loki", or empty to disable
3939+ Backend string `yaml:"backend"`
4040+4141+ // URL is the remote log service endpoint (from env: ATCR_LOG_SHIPPER_URL)
4242+ URL string `yaml:"url"`
4343+4444+ // BatchSize is the number of logs to batch before flushing (from env: ATCR_LOG_SHIPPER_BATCH_SIZE, default: 100)
4545+ BatchSize int `yaml:"batch_size"`
4646+4747+ // FlushInterval is the max time between flushes (from env: ATCR_LOG_SHIPPER_FLUSH_INTERVAL, default: 5s)
4848+ FlushInterval time.Duration `yaml:"flush_interval"`
4949+5050+ // Username for basic auth (from env: ATCR_LOG_SHIPPER_USERNAME, optional)
5151+ Username string `yaml:"username"`
5252+5353+ // Password for basic auth (from env: ATCR_LOG_SHIPPER_PASSWORD, optional)
5454+ Password string `yaml:"password"`
3355}
34563557// ServerConfig defines server settings
···4870 // TestMode enables HTTP for local DID resolution and transition:generic scope (from env: TEST_MODE)
4971 TestMode bool `yaml:"test_mode"`
50725151- // DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001")
5252- DebugAddr string `yaml:"debug_addr"`
5353-5473 // OAuthKeyPath is the path to the OAuth client P-256 signing key (from env: ATCR_OAUTH_KEY_PATH, default: "/var/lib/atcr/oauth/client.key")
5574 // Auto-generated on first run for production (non-localhost) deployments
5675 OAuthKeyPath string `yaml:"oauth_key_path"`
···62816382// UIConfig defines web UI settings
6483type UIConfig struct {
6565- // Enabled controls whether the web UI is enabled (from env: ATCR_UI_ENABLED, default: true)
6666- Enabled bool `yaml:"enabled"`
6767-6884 // DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db")
6985 DatabasePath string `yaml:"database_path"`
7070-7171- // SkipDBMigrations controls whether to skip running database migrations (from env: SKIP_DB_MIGRATIONS, default: false)
7272- SkipDBMigrations bool `yaml:"skip_db_migrations"`
7386}
74877588// HealthConfig defines health check and cache settings
···112125 ServiceName string `yaml:"service_name"`
113126}
114127115115-// CredentialHelperConfig defines credential helper version and download settings
128128+// CredentialHelperConfig defines credential helper download settings
116129type CredentialHelperConfig struct {
117117- // Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION)
118118- // e.g., "v0.0.2"
119119- Version string `yaml:"version"`
120120-121121- // TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO)
122122- // Default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
130130+ // TangledRepo is the Tangled repository URL for downloads
131131+ // Hardcoded default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
123132 TangledRepo string `yaml:"tangled_repo"`
124124-125125- // Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS)
126126- // e.g., "linux_amd64:abc123,darwin_arm64:def456"
127127- Checksums map[string]string `yaml:"-"`
128133}
129134130135// LoadConfigFromEnv builds a complete configuration from environment variables
···137142 // Logging configuration
138143 cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
139144145145+ // Log shipper configuration
146146+ cfg.LogShipper.Backend = os.Getenv("ATCR_LOG_SHIPPER_BACKEND")
147147+ cfg.LogShipper.URL = os.Getenv("ATCR_LOG_SHIPPER_URL")
148148+ cfg.LogShipper.BatchSize = getIntOrDefault("ATCR_LOG_SHIPPER_BATCH_SIZE", 100)
149149+ cfg.LogShipper.FlushInterval = getDurationOrDefault("ATCR_LOG_SHIPPER_FLUSH_INTERVAL", 5*time.Second)
150150+ cfg.LogShipper.Username = os.Getenv("ATCR_LOG_SHIPPER_USERNAME")
151151+ cfg.LogShipper.Password = os.Getenv("ATCR_LOG_SHIPPER_PASSWORD")
152152+140153 // Server configuration
141154 cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
142142- cfg.Server.DebugAddr = getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
143155 cfg.Server.DefaultHoldDID = os.Getenv("ATCR_DEFAULT_HOLD_DID")
144156 if cfg.Server.DefaultHoldDID == "" {
145157 return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
···155167 }
156168157169 // UI configuration
158158- cfg.UI.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false"
159170 cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db")
160160- cfg.UI.SkipDBMigrations = os.Getenv("SKIP_DB_MIGRATIONS") == "true"
161171162172 // Health and cache configuration
163173 cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
···184194 // Derive service name from base URL or env var (used for JWT issuer and service)
185195 cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL)
186196187187- // Credential helper configuration
188188- cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION")
189189- cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry")
190190- cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS"))
197197+ // Credential helper configuration (hardcoded - no env vars needed)
198198+ cfg.CredentialHelper.TangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
191199192200 // Build distribution configuration for compatibility with distribution library
193201 distConfig, err := buildDistributionConfig(cfg)
···232240 Secret: httpSecret,
233241 Headers: map[string][]string{
234242 "X-Content-Type-Options": {"nosniff"},
235235- },
236236- Debug: configuration.Debug{
237237- Addr: cfg.Server.DebugAddr,
238243 },
239244 }
240245···380385 return parsed
381386}
382387383383-// parseChecksums parses a comma-separated list of platform:sha256 pairs
384384-// e.g., "linux_amd64:abc123,darwin_arm64:def456"
385385-func parseChecksums(checksumsStr string) map[string]string {
386386- checksums := make(map[string]string)
387387- if checksumsStr == "" {
388388- return checksums
388388+// getIntOrDefault parses an int from environment variable or returns default
389389+// Logs a warning if parsing fails
390390+func getIntOrDefault(envKey string, defaultValue int) int {
391391+ envVal := os.Getenv(envKey)
392392+ if envVal == "" {
393393+ return defaultValue
389394 }
390395391391- for pair := range strings.SplitSeq(checksumsStr, ",") {
392392- parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
393393- if len(parts) == 2 {
394394- platform := strings.TrimSpace(parts[0])
395395- hash := strings.TrimSpace(parts[1])
396396- if platform != "" && hash != "" {
397397- checksums[platform] = hash
398398- }
399399- }
396396+ parsed, err := strconv.Atoi(envVal)
397397+ if err != nil {
398398+ slog.Warn("Invalid int, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
399399+ return defaultValue
400400 }
401401- return checksums
401401+402402+ return parsed
402403}
+1-1
pkg/appview/db/annotations_test.go
···2121func setupAnnotationsTestDB(t *testing.T) *sql.DB {
2222 t.Helper()
2323 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB
2424- db, err := InitDB("file::memory:?cache=shared", true)
2424+ db, err := InitDB("file::memory:?cache=shared")
2525 if err != nil {
2626 t.Fatalf("Failed to initialize test database: %v", err)
2727 }
+1-1
pkg/appview/db/device_store_test.go
···1414 t.Helper()
1515 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB
1616 // This prevents race conditions where different connections see different databases
1717- db, err := InitDB("file::memory:?cache=shared", true)
1717+ db, err := InitDB("file::memory:?cache=shared")
1818 if err != nil {
1919 t.Fatalf("Failed to initialize test database: %v", err)
2020 }
+1-1
pkg/appview/db/hold_store_test.go
···8181func setupHoldTestDB(t *testing.T) *sql.DB {
8282 t.Helper()
8383 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB
8484- db, err := InitDB("file::memory:?cache=shared", true)
8484+ db, err := InitDB("file::memory:?cache=shared")
8585 if err != nil {
8686 t.Fatalf("Failed to initialize test database: %v", err)
8787 }
···2626var schemaSQL string
27272828// InitDB initializes the SQLite database with the schema
2929-func InitDB(path string, skipMigrations bool) (*sql.DB, error) {
2929+func InitDB(path string) (*sql.DB, error) {
3030 db, err := sql.Open("sqlite3", path)
3131 if err != nil {
3232 return nil, err
···5454 }
5555 }
56565757- // Run migrations unless skipped
5757+ // Run migrations
5858 // For fresh databases, migrations are recorded but not executed (schema.sql is already complete)
5959- if !skipMigrations {
6060- if err := runMigrations(db, !isExisting); err != nil {
6161- return nil, err
6262- }
5959+ if err := runMigrations(db, !isExisting); err != nil {
6060+ return nil, err
6361 }
64626563 return db, nil
+1-1
pkg/appview/db/session_store_test.go
···1313func setupSessionTestDB(t *testing.T) *SessionStore {
1414 t.Helper()
1515 // Use file::memory: with cache=shared to ensure all connections share the same in-memory DB
1616- db, err := InitDB("file::memory:?cache=shared", true)
1616+ db, err := InitDB("file::memory:?cache=shared")
1717 if err != nil {
1818 t.Fatalf("Failed to initialize test database: %v", err)
1919 }
+1-1
pkg/appview/db/tag_delete_test.go
···1111// This simulates what Jetstream does: encode repo/tag to rkey, then decode and delete
1212func TestTagDeleteRoundTrip(t *testing.T) {
1313 // Create in-memory test database
1414- db, err := InitDB(":memory:", true)
1414+ db, err := InitDB(":memory:")
1515 if err != nil {
1616 t.Fatalf("Failed to init database: %v", err)
1717 }
+7-37
pkg/appview/handlers/api.go
···66 "fmt"
77 "log/slog"
88 "net/http"
99- "strings"
1091110 "atcr.io/pkg/appview/db"
1211 "atcr.io/pkg/appview/middleware"
···246245}
247246248247// CredentialHelperVersionHandler returns the latest credential helper version info
248248+// Note: Version info is fetched dynamically from TangledRepo's releases
249249type CredentialHelperVersionHandler struct {
250250- Version string
251250 TangledRepo string
252252- Checksums map[string]string
253253-}
254254-255255-// Supported platforms for download URLs
256256-var credentialHelperPlatforms = []struct {
257257- key string // API key (e.g., "linux_amd64")
258258- os string // OS name in archive (e.g., "Linux")
259259- arch string // Arch name in archive (e.g., "x86_64")
260260- ext string // Archive extension (e.g., "tar.gz" or "zip")
261261-}{
262262- {"linux_amd64", "Linux", "x86_64", "tar.gz"},
263263- {"linux_arm64", "Linux", "arm64", "tar.gz"},
264264- {"darwin_amd64", "Darwin", "x86_64", "tar.gz"},
265265- {"darwin_arm64", "Darwin", "arm64", "tar.gz"},
266266- {"windows_amd64", "Windows", "x86_64", "zip"},
267267- {"windows_arm64", "Windows", "arm64", "zip"},
268251}
269252270253func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
271271- // Check if version is configured
272272- if h.Version == "" {
273273- http.Error(w, "Credential helper version not configured", http.StatusServiceUnavailable)
274274- return
275275- }
276276-277277- // Build download URLs for all platforms
278278- // URL format: {TangledRepo}/tags/{version}/download/docker-credential-atcr_{version_without_v}_{OS}_{Arch}.{ext}
279279- downloadURLs := make(map[string]string)
280280- versionWithoutV := strings.TrimPrefix(h.Version, "v")
281281-282282- for _, p := range credentialHelperPlatforms {
283283- filename := fmt.Sprintf("docker-credential-atcr_%s_%s_%s.%s", versionWithoutV, p.os, p.arch, p.ext)
284284- downloadURLs[p.key] = fmt.Sprintf("%s/tags/%s/download/%s", h.TangledRepo, h.Version, filename)
285285- }
286286-254254+ // This endpoint directs users to the Tangled repository for downloads
255255+ // Version info should be fetched from the repository's releases page
287256 response := CredentialHelperVersionResponse{
288288- Latest: h.Version,
289289- DownloadURLs: downloadURLs,
290290- Checksums: h.Checksums,
257257+ Latest: "",
258258+ DownloadURLs: map[string]string{"tangled_repo": h.TangledRepo},
259259+ Checksums: nil,
260260+ ReleaseNotes: "Visit the Tangled repository for the latest releases: " + h.TangledRepo,
291261 }
292262293263 render.SetContentType(render.ContentTypeJSON)
+1-1
pkg/appview/handlers/device_test.go
···18181919// setupTestDB creates an in-memory SQLite database with full schema for testing
2020func setupTestDB(t *testing.T) *sql.DB {
2121- database, err := db.InitDB(":memory:", true)
2121+ database, err := db.InitDB(":memory:")
2222 if err != nil {
2323 t.Fatalf("Failed to initialize test database: %v", err)
2424 }
···34343535// setupTestDB creates an in-memory database for testing
3636func setupTestDB(t *testing.T) *sql.DB {
3737- testDB, err := db.InitDB(":memory:", true)
3737+ testDB, err := db.InitDB(":memory:")
3838 if err != nil {
3939 t.Fatalf("Failed to initialize test database: %v", err)
4040 }
+1-1
pkg/auth/token/handler_test.go
···52525353// setupTestDeviceStore creates an in-memory SQLite database for testing
5454func setupTestDeviceStore(t *testing.T) (*db.DeviceStore, *sql.DB) {
5555- testDB, err := db.InitDB(":memory:", true)
5555+ testDB, err := db.InitDB(":memory:")
5656 if err != nil {
5757 t.Fatalf("Failed to initialize test database: %v", err)
5858 }
+63
pkg/hold/config.go
···2424type Config struct {
2525 Version string `yaml:"version"`
2626 LogLevel string `yaml:"log_level"`
2727+ LogShipper LogShipperConfig `yaml:"log_shipper"`
2728 Storage StorageConfig `yaml:"storage"`
2829 Server ServerConfig `yaml:"server"`
2930 Registration RegistrationConfig `yaml:"registration"`
···3132 Admin AdminConfig `yaml:"admin"`
3233}
33343535+// LogShipperConfig defines remote log shipping settings
3636+type LogShipperConfig struct {
3737+ // Backend selects the log shipping backend (from env: ATCR_LOG_SHIPPER_BACKEND)
3838+ // Valid values: "victoria", "opensearch", "loki", or empty to disable
3939+ Backend string `yaml:"backend"`
4040+4141+ // URL is the remote log service endpoint (from env: ATCR_LOG_SHIPPER_URL)
4242+ URL string `yaml:"url"`
4343+4444+ // BatchSize is the number of logs to batch before flushing (from env: ATCR_LOG_SHIPPER_BATCH_SIZE, default: 100)
4545+ BatchSize int `yaml:"batch_size"`
4646+4747+ // FlushInterval is the max time between flushes (from env: ATCR_LOG_SHIPPER_FLUSH_INTERVAL, default: 5s)
4848+ FlushInterval time.Duration `yaml:"flush_interval"`
4949+5050+ // Username for basic auth (from env: ATCR_LOG_SHIPPER_USERNAME, optional)
5151+ Username string `yaml:"username"`
5252+5353+ // Password for basic auth (from env: ATCR_LOG_SHIPPER_PASSWORD, optional)
5454+ Password string `yaml:"password"`
5555+}
5656+3457// AdminConfig defines admin panel settings
3558type AdminConfig struct {
3659 // Enabled controls whether the admin panel is accessible (from env: HOLD_ADMIN_ENABLED)
···113136114137 // Logging configuration
115138 cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
139139+140140+ // Log shipper configuration
141141+ cfg.LogShipper.Backend = os.Getenv("ATCR_LOG_SHIPPER_BACKEND")
142142+ cfg.LogShipper.URL = os.Getenv("ATCR_LOG_SHIPPER_URL")
143143+ cfg.LogShipper.BatchSize = getIntOrDefault("ATCR_LOG_SHIPPER_BATCH_SIZE", 100)
144144+ cfg.LogShipper.FlushInterval = getDurationOrDefault("ATCR_LOG_SHIPPER_FLUSH_INTERVAL", 5*time.Second)
145145+ cfg.LogShipper.Username = os.Getenv("ATCR_LOG_SHIPPER_USERNAME")
146146+ cfg.LogShipper.Password = os.Getenv("ATCR_LOG_SHIPPER_PASSWORD")
116147117148 // Server configuration
118149 cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080")
···215246 return val
216247 }
217248 return defaultValue
249249+}
250250+251251+// getIntOrDefault parses an int from environment variable or returns default
252252+func getIntOrDefault(envKey string, defaultValue int) int {
253253+ envVal := os.Getenv(envKey)
254254+ if envVal == "" {
255255+ return defaultValue
256256+ }
257257+258258+ var parsed int
259259+ if _, err := fmt.Sscanf(envVal, "%d", &parsed); err != nil {
260260+ slog.Warn("Invalid int, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
261261+ return defaultValue
262262+ }
263263+264264+ return parsed
265265+}
266266+267267+// getDurationOrDefault parses a duration from environment variable or returns default
268268+func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration {
269269+ envVal := os.Getenv(envKey)
270270+ if envVal == "" {
271271+ return defaultValue
272272+ }
273273+274274+ parsed, err := time.ParseDuration(envVal)
275275+ if err != nil {
276276+ slog.Warn("Invalid duration, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
277277+ return defaultValue
278278+ }
279279+280280+ return parsed
218281}
219282220283// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
+42-2
pkg/logging/logger.go
···2727 debugEnabled atomic.Bool
2828 revertTimer *time.Timer
2929 revertMu sync.Mutex
3030+3131+ // asyncHandler holds the global async handler for shutdown
3232+ asyncHandler *AsyncHandler
3033)
31343235// InitLogger initializes the global slog default logger with the specified log level.
···3639//
3740// Also starts a signal handler for SIGUSR1 to toggle debug mode at runtime.
3841func InitLogger(level string) {
4242+ InitLoggerWithShipper(level, ShipperConfig{})
4343+}
4444+4545+// InitLoggerWithShipper initializes the global slog default logger with the specified
4646+// log level and optional remote log shipping.
4747+// Valid levels: debug, info, warn, error (case-insensitive)
4848+// If level is empty or invalid, defaults to INFO.
4949+// Call this from main() at startup.
5050+//
5151+// If shipperCfg.Backend is non-empty, logs will be shipped to the configured
5252+// remote service in addition to stdout.
5353+//
5454+// Also starts a signal handler for SIGUSR1 to toggle debug mode at runtime.
5555+func InitLoggerWithShipper(level string, shipperCfg ShipperConfig) {
3956 var logLevel slog.Level
40574158 switch strings.ToLower(strings.TrimSpace(level)) {
···6986 },
7087 }
71887272- handler := slog.NewTextHandler(os.Stdout, opts)
7373- slog.SetDefault(slog.New(handler))
8989+ // Create stdout handler
9090+ stdoutHandler := slog.NewTextHandler(os.Stdout, opts)
9191+9292+ // Create shipper if configured
9393+ var shipper Shipper
9494+ if shipperCfg.Backend != "" {
9595+ var err error
9696+ shipper, err = NewShipper(shipperCfg)
9797+ if err != nil {
9898+ // Log error but continue without shipping
9999+ fmt.Fprintf(os.Stderr, "log shipper initialization failed: %v (continuing with stdout only)\n", err)
100100+ }
101101+ }
102102+103103+ // Create async handler (wraps stdout + optional shipper)
104104+ asyncHandler = NewAsyncHandler(stdoutHandler, shipper, shipperCfg, opts)
105105+ slog.SetDefault(slog.New(asyncHandler))
7410675107 // Start signal handler for dynamic debug toggle
76108 go handleDebugSignal()
109109+}
110110+111111+// Shutdown flushes any remaining logs and closes the log shipper.
112112+// Call this during graceful shutdown to ensure all logs are delivered.
113113+func Shutdown() {
114114+ if asyncHandler != nil {
115115+ asyncHandler.Shutdown()
116116+ }
77117}
7811879119func handleDebugSignal() {
+308
pkg/logging/shipper.go
···11+// Package logging provides centralized structured logging with optional remote log shipping.
22+package logging
33+44+import (
55+ "context"
66+ "fmt"
77+ "log/slog"
88+ "sync"
99+ "time"
1010+)
1111+1212+// Default configuration values
1313+const (
1414+ DefaultBatchSize = 100
1515+ DefaultFlushInterval = 5 * time.Second
1616+)
1717+1818+// Shipper defines the interface for log shipping backends.
1919+// Implementations should be safe for concurrent use.
2020+type Shipper interface {
2121+ // Ship sends a batch of log entries to the remote service.
2222+ // Returns an error if the batch could not be shipped.
2323+ Ship(ctx context.Context, entries []LogEntry) error
2424+2525+ // Close cleanly shuts down the shipper, releasing any resources.
2626+ Close() error
2727+}
2828+2929+// LogEntry represents a single log entry to be shipped.
3030+type LogEntry struct {
3131+ Time time.Time
3232+ Level slog.Level
3333+ Message string
3434+ Source string
3535+ Attrs map[string]any
3636+}
3737+3838+// ShipperConfig configures the log shipper.
3939+type ShipperConfig struct {
4040+ // Backend selects the shipping backend: "victoria", "opensearch", "loki", etc.
4141+ // Empty string disables remote shipping (stdout only).
4242+ Backend string
4343+4444+ // URL is the remote service endpoint URL.
4545+ URL string
4646+4747+ // BatchSize is the number of logs to batch before flushing.
4848+ // Default: 100
4949+ BatchSize int
5050+5151+ // FlushInterval is the maximum time between flushes.
5252+ // Default: 5s
5353+ FlushInterval time.Duration
5454+5555+ // Service identifies the source service ("appview" or "hold").
5656+ // Added to all log entries.
5757+ Service string
5858+5959+ // Username for basic auth (optional).
6060+ Username string
6161+6262+ // Password for basic auth (optional).
6363+ Password string
6464+}
6565+6666+// NewShipper creates a shipper for the configured backend.
6767+// Returns nil if no backend is configured (remote shipping disabled).
6868+func NewShipper(cfg ShipperConfig) (Shipper, error) {
6969+ switch cfg.Backend {
7070+ case "victoria":
7171+ return NewVictoriaShipper(cfg)
7272+ case "opensearch":
7373+ return nil, fmt.Errorf("opensearch backend not yet implemented")
7474+ case "loki":
7575+ return nil, fmt.Errorf("loki backend not yet implemented")
7676+ case "":
7777+ return nil, nil // No remote shipping
7878+ default:
7979+ return nil, fmt.Errorf("unknown log shipper backend: %s", cfg.Backend)
8080+ }
8181+}
8282+8383+// asyncState holds the shared state for async log shipping.
8484+// This is separate from AsyncHandler to allow WithAttrs/WithGroup
8585+// to create new handlers that share the same batch and flush state.
8686+type asyncState struct {
8787+ shipper Shipper
8888+8989+ // Batching
9090+ batch []LogEntry
9191+ batchMu sync.Mutex
9292+ batchSize int
9393+9494+ // Async flush
9595+ flushInterval time.Duration
9696+ flushCh chan struct{}
9797+ doneCh chan struct{}
9898+ wg sync.WaitGroup
9999+}
100100+101101+// AsyncHandler is an slog.Handler that writes to stdout and optionally
102102+// ships logs to a remote service asynchronously.
103103+type AsyncHandler struct {
104104+ stdout slog.Handler
105105+ opts *slog.HandlerOptions
106106+ state *asyncState // Shared state for batching and flushing
107107+}
108108+109109+// NewAsyncHandler creates a new AsyncHandler that wraps stdout logging
110110+// and optionally ships logs to a remote service.
111111+func NewAsyncHandler(stdout slog.Handler, shipper Shipper, cfg ShipperConfig, opts *slog.HandlerOptions) *AsyncHandler {
112112+ batchSize := cfg.BatchSize
113113+ if batchSize <= 0 {
114114+ batchSize = DefaultBatchSize
115115+ }
116116+117117+ flushInterval := cfg.FlushInterval
118118+ if flushInterval <= 0 {
119119+ flushInterval = DefaultFlushInterval
120120+ }
121121+122122+ state := &asyncState{
123123+ shipper: shipper,
124124+ batch: make([]LogEntry, 0, batchSize),
125125+ batchSize: batchSize,
126126+ flushInterval: flushInterval,
127127+ flushCh: make(chan struct{}, 1),
128128+ doneCh: make(chan struct{}),
129129+ }
130130+131131+ h := &AsyncHandler{
132132+ stdout: stdout,
133133+ opts: opts,
134134+ state: state,
135135+ }
136136+137137+ // Start background flusher if shipping is enabled
138138+ if shipper != nil {
139139+ state.wg.Add(1)
140140+ go h.runFlusher()
141141+ }
142142+143143+ return h
144144+}
145145+146146+// Enabled reports whether the handler handles records at the given level.
147147+func (h *AsyncHandler) Enabled(ctx context.Context, level slog.Level) bool {
148148+ return h.stdout.Enabled(ctx, level)
149149+}
150150+151151+// Handle handles the Record by writing to stdout and queuing for remote shipping.
152152+func (h *AsyncHandler) Handle(ctx context.Context, r slog.Record) error {
153153+ // Always write to stdout
154154+ if err := h.stdout.Handle(ctx, r); err != nil {
155155+ return err
156156+ }
157157+158158+ // Skip remote shipping if no shipper configured
159159+ if h.state.shipper == nil {
160160+ return nil
161161+ }
162162+163163+ // Build log entry
164164+ entry := LogEntry{
165165+ Time: r.Time,
166166+ Level: r.Level,
167167+ Message: r.Message,
168168+ Attrs: make(map[string]any),
169169+ }
170170+171171+ r.Attrs(func(a slog.Attr) bool {
172172+ if a.Key == slog.SourceKey {
173173+ if src, ok := a.Value.Any().(*slog.Source); ok {
174174+ entry.Source = shortenSource(src.File, src.Line)
175175+ }
176176+ } else {
177177+ entry.Attrs[a.Key] = resolveAttrValue(a.Value)
178178+ }
179179+ return true
180180+ })
181181+182182+ // Add to batch
183183+ h.state.batchMu.Lock()
184184+ h.state.batch = append(h.state.batch, entry)
185185+ shouldFlush := len(h.state.batch) >= h.state.batchSize
186186+ h.state.batchMu.Unlock()
187187+188188+ if shouldFlush {
189189+ h.triggerFlush()
190190+ }
191191+192192+ return nil
193193+}
194194+195195+// WithAttrs returns a new Handler with the given attributes added.
196196+func (h *AsyncHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
197197+ return &AsyncHandler{
198198+ stdout: h.stdout.WithAttrs(attrs),
199199+ opts: h.opts,
200200+ state: h.state, // Share the same state
201201+ }
202202+}
203203+204204+// WithGroup returns a new Handler with the given group name.
205205+func (h *AsyncHandler) WithGroup(name string) slog.Handler {
206206+ return &AsyncHandler{
207207+ stdout: h.stdout.WithGroup(name),
208208+ opts: h.opts,
209209+ state: h.state, // Share the same state
210210+ }
211211+}
212212+213213+// triggerFlush signals the flusher goroutine to flush immediately.
214214+func (h *AsyncHandler) triggerFlush() {
215215+ select {
216216+ case h.state.flushCh <- struct{}{}:
217217+ default: // Flush already pending
218218+ }
219219+}
220220+221221+// runFlusher runs in a goroutine and periodically flushes the batch.
222222+func (h *AsyncHandler) runFlusher() {
223223+ defer h.state.wg.Done()
224224+225225+ ticker := time.NewTicker(h.state.flushInterval)
226226+ defer ticker.Stop()
227227+228228+ for {
229229+ select {
230230+ case <-ticker.C:
231231+ h.flush()
232232+ case <-h.state.flushCh:
233233+ h.flush()
234234+ case <-h.state.doneCh:
235235+ h.flush() // Final flush
236236+ return
237237+ }
238238+ }
239239+}
240240+241241+// flush sends the current batch to the remote service.
242242+func (h *AsyncHandler) flush() {
243243+ h.state.batchMu.Lock()
244244+ if len(h.state.batch) == 0 {
245245+ h.state.batchMu.Unlock()
246246+ return
247247+ }
248248+249249+ // Take ownership of the batch
250250+ batch := h.state.batch
251251+ h.state.batch = make([]LogEntry, 0, h.state.batchSize)
252252+ h.state.batchMu.Unlock()
253253+254254+ // Ship with a timeout context
255255+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
256256+ defer cancel()
257257+258258+ if err := h.state.shipper.Ship(ctx, batch); err != nil {
259259+ // Log to stderr (not through slog to avoid recursion)
260260+ fmt.Printf("log shipper error: %v (dropped %d entries)\n", err, len(batch))
261261+ }
262262+}
263263+264264+// Shutdown flushes any remaining logs and closes the shipper.
265265+// Call this during graceful shutdown.
266266+func (h *AsyncHandler) Shutdown() {
267267+ if h.state.shipper == nil {
268268+ return
269269+ }
270270+271271+ close(h.state.doneCh)
272272+ h.state.wg.Wait()
273273+274274+ if err := h.state.shipper.Close(); err != nil {
275275+ fmt.Printf("log shipper close error: %v\n", err)
276276+ }
277277+}
278278+279279+// resolveAttrValue converts slog.Value to a plain Go value for JSON encoding.
280280+func resolveAttrValue(v slog.Value) any {
281281+ switch v.Kind() {
282282+ case slog.KindString:
283283+ return v.String()
284284+ case slog.KindInt64:
285285+ return v.Int64()
286286+ case slog.KindUint64:
287287+ return v.Uint64()
288288+ case slog.KindFloat64:
289289+ return v.Float64()
290290+ case slog.KindBool:
291291+ return v.Bool()
292292+ case slog.KindDuration:
293293+ return v.Duration().String()
294294+ case slog.KindTime:
295295+ return v.Time().Format(time.RFC3339Nano)
296296+ case slog.KindGroup:
297297+ attrs := v.Group()
298298+ m := make(map[string]any, len(attrs))
299299+ for _, a := range attrs {
300300+ m[a.Key] = resolveAttrValue(a.Value)
301301+ }
302302+ return m
303303+ case slog.KindAny:
304304+ return v.Any()
305305+ default:
306306+ return v.String()
307307+ }
308308+}