Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fda8a17
feat(observability): attach token metadata to async events
mfittko Mar 28, 2026
7b08040
feat(dispatcher): add CloudWatch logs backend
mfittko Mar 28, 2026
364210a
fix(review): address cloudwatch observability feedback
mfittko Mar 28, 2026
bdcc3f9
fix(mysql): use portable token metadata migration syntax
mfittko Mar 28, 2026
f91771a
test(coverage): raise cloudwatch helper coverage
mfittko Mar 28, 2026
d2d44ba
[observability] Fix CloudWatch model and token usage extraction
mfittko Mar 28, 2026
779f3e2
[observability] Broaden CloudWatch fallback extraction
mfittko Mar 28, 2026
fe7879f
[observability] Support streaming token fallback
mfittko Mar 29, 2026
5ab970d
[observability] Emit finish events for completed requests
mfittko Mar 29, 2026
c0317e5
[observability] Fix duration and responses token fallback
mfittko Mar 29, 2026
34a4ec6
[observability] Decode compressed responses for token usage
mfittko Mar 29, 2026
a9c9c14
[observability] Count responses input in fallback usage
mfittko Mar 29, 2026
d15f265
[observability] standardize usage logging schema
mfittko Mar 29, 2026
b9e22ef
[eventbus] make redis streams publish non-blocking
mfittko Mar 29, 2026
36055fb
[observability] remove flat cloudwatch token fields
mfittko Mar 29, 2026
d443882
[observability] add support for embeddings in event transformation
mfittko Mar 30, 2026
5c87ca3
[proxy] Add request cache namespace support
mfittko Mar 30, 2026
c18c942
[observability] Add comprehensive tests for map cloning and response …
mfittko Mar 30, 2026
c85850b
chore(token): document clone metadata helper
mfittko Mar 31, 2026
7c9ace7
[observability] Enhance logger with safe write syncer and correspondi…
mfittko Mar 31, 2026
d47cbf0
[observability] Enhance OpenAI response transformation to support aud…
mfittko Mar 31, 2026
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
25 changes: 23 additions & 2 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ func proxyLatencyFromProxyTimingHeaders(headers http.Header) (time.Duration, boo
return finalAt.Sub(receivedAt), true
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}

return ""
}

func newBenchmarkHTTPClient(timeout time.Duration) *http.Client {
// NOTE: We intentionally reuse a single client+transport for all requests so we
// get connection pooling/keep-alives. Creating a new client per request
Expand Down Expand Up @@ -354,7 +364,7 @@ var benchmarkCmd *cobra.Command
var dispatcherCmd = &cobra.Command{
Use: "dispatcher",
Short: "Run the event dispatcher service",
Long: `Run the event dispatcher service with pluggable backends. Supports file, lunary, and helicone services.`,
Long: `Run the event dispatcher service with pluggable backends. Supports file, lunary, helicone, and cloudwatch services.`,
Run: runDispatcher,
}

Expand Down Expand Up @@ -501,6 +511,17 @@ func runDispatcher(cmd *cobra.Command, args []string) {
config["api-key"] = envKey
}
}
if dispatcherService == "cloudwatch" {
if logGroup := os.Getenv("DISPATCHER_CLOUDWATCH_LOG_GROUP"); logGroup != "" {
config["log-group"] = logGroup
}
if logStream := os.Getenv("DISPATCHER_CLOUDWATCH_LOG_STREAM"); logStream != "" {
config["log-stream"] = logStream
}
if region := firstNonEmpty(os.Getenv("DISPATCHER_CLOUDWATCH_REGION"), os.Getenv("AWS_REGION"), os.Getenv("AWS_DEFAULT_REGION")); region != "" {
config["region"] = region
}
}

if err := plugin.Init(config); err != nil {
logger.Fatal("Failed to initialize plugin", zap.Error(err))
Expand Down Expand Up @@ -1590,7 +1611,7 @@ Latency breakdown:
cobraRoot.PersistentFlags().StringVar(&manageAPIBaseURL, "manage-api-base-url", "http://localhost:8080", "Base URL for management API (default: http://localhost:8080)")

// Add dispatcher command flags
dispatcherCmd.Flags().StringVar(&dispatcherService, "service", config.EnvOrDefault("DISPATCHER_SERVICE", "file"), "Dispatcher service type (file, lunary, helicone)")
dispatcherCmd.Flags().StringVar(&dispatcherService, "service", config.EnvOrDefault("DISPATCHER_SERVICE", "file"), "Dispatcher service type (file, lunary, helicone, cloudwatch)")
dispatcherCmd.Flags().StringVar(&dispatcherEndpoint, "endpoint", config.EnvOrDefault("DISPATCHER_ENDPOINT", ""), "Dispatcher endpoint URL (file: path, lunary/helicone: API endpoint)")
dispatcherCmd.Flags().StringVar(&dispatcherAPIKey, "api-key", config.EnvOrDefault("LLM_PROXY_API_KEY", ""), "API key for external services (lunary, helicone)")
dispatcherCmd.Flags().IntVar(&dispatcherBuffer, "buffer", config.EnvIntOrDefault("DISPATCHER_BUFFER", 1000), "Event bus buffer size")
Expand Down
1 change: 1 addition & 0 deletions docs/guides/api-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ The caching system follows HTTP standards:
- **Authentication**: Cached responses for authenticated requests are only served if marked as publicly cacheable (`Cache-Control: public` or `s-maxage` present)
- **Streaming responses**: Captured during streaming and stored after completion
- **TTL precedence**: `s-maxage` (shared cache) takes precedence over `max-age`
- **Optional namespace header**: Clients may send `X-LLM-Proxy-Cache-Namespace` to partition cache keys for logical invalidation; the value is sanitized, included in the proxy cache key, and not forwarded upstream
- **Headers**: Responses include `X-PROXY-CACHE`, `X-PROXY-CACHE-KEY`, and `Cache-Status` for observability

### Cache Stats Aggregation
Expand Down
15 changes: 15 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ go 1.23.9
require (
github.com/alicebob/miniredis/v2 v2.31.0
github.com/andybalholm/brotli v1.2.0
github.com/aws/aws-sdk-go-v2/config v1.31.0
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.58.0
github.com/chzyer/readline v1.5.1
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.10.1
Expand All @@ -28,6 +30,19 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/aws/aws-sdk-go-v2 v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
Expand Down
30 changes: 30 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpS
github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4=
github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.0 h1:9yH0xiY5fUnVNLRWO0AtayqwU1ndriZdN78LlhruJR4=
github.com/aws/aws-sdk-go-v2/config v1.31.0/go.mod h1:VeV3K72nXnhbe4EuxxhzsDc/ByrCSlZwUnWH52Nde/I=
github.com/aws/aws-sdk-go-v2/credentials v1.18.13 h1:gkpEm65/ZfrGJ3wbFH++Ki7DyaWtsWbK9idX6OXCo2E=
github.com/aws/aws-sdk-go-v2/credentials v1.18.13/go.mod h1:eVTHz1yI2/WIlXTE8f70mcrSxNafXD5sJpTIM9f+kmo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 h1:Is2tPmieqGS2edBnmOJIbdvOA6Op+rRpaYR60iBAwXM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7/go.mod h1:F1i5V5421EGci570yABvpIXgRIBPb5JM+lSkHF6Dq5w=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 h1:UCxq0X9O3xrlENdKf1r9eRJoKz/b0AfGkpp3a7FPlhg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7/go.mod h1:rHRoJUNUASj5Z/0eqI4w32vKvC7atoWR0jC+IkmVH8k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 h1:Y6DTZUn7ZUC4th9FMBbo8LVE+1fyq3ofw+tRwkUd3PY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7/go.mod h1:x3XE6vMnU9QvHN/Wrx2s44kwzV2o2g5x/siw4ZUJ9g8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.58.0 h1:XH0kj0KcoKd+BAadpiS83/Wf+25q4FmH3gDei4u+PzA=
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.58.0/go.mod h1:ptJgRWK9opQK1foOTBKUg3PokkKA0/xcTXWIxwliaIY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 h1:mLgc5QIgOy26qyh5bvW+nDoAppxgn3J2WV3m9ewq7+8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7/go.mod h1:wXb/eQnqt8mDQIQTTmcw58B5mYGxzLGZGK8PWNFZ0BA=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 h1:7PKX3VYsZ8LUWceVRuv0+PU+E7OtQb1lgmi5vmUE9CM=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.3/go.mod h1:Ql6jE9kyyWI5JHn+61UT/Y5Z0oyVJGmgmJbZD5g4unY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.5 h1:gBBZmSuIySGqDLtXdZiYpwyzbJKXQD2jjT0oDY6ywbo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.5/go.mod h1:XclEty74bsGBCr1s0VSaA11hQ4ZidK4viWK7rRfO88I=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 h1:PR00NXRYgY4FWHqOGx3fC3lhVKjsp1GdloDv2ynMSd8=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.4/go.mod h1:Z+Gd23v97pX9zK97+tX4ppAgqCt3Z2dIXB02CtBncK8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE tokens ADD COLUMN metadata TEXT;

-- +goose Down
ALTER TABLE tokens DROP COLUMN metadata;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE tokens ADD COLUMN IF NOT EXISTS metadata TEXT;

-- +goose Down
ALTER TABLE tokens DROP COLUMN IF EXISTS metadata;
1 change: 1 addition & 0 deletions internal/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Token struct {
ID string `json:"id"`
Token string `json:"token"`
ProjectID string `json:"project_id"`
Metadata *string `json:"metadata,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
DeactivatedAt *time.Time `json:"deactivated_at,omitempty"`
Expand Down
68 changes: 60 additions & 8 deletions internal/database/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
Expand All @@ -29,8 +30,8 @@ func (d *DB) CreateToken(ctx context.Context, token Token) error {
}

query := `
INSERT INTO tokens (id, token, project_id, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO tokens (id, token, project_id, metadata, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`

_, err := d.ExecContextRebound(
Expand All @@ -39,6 +40,7 @@ func (d *DB) CreateToken(ctx context.Context, token Token) error {
token.ID,
token.Token,
token.ProjectID,
token.Metadata,
token.ExpiresAt,
token.IsActive,
nil,
Expand All @@ -57,19 +59,21 @@ func (d *DB) CreateToken(ctx context.Context, token Token) error {
// GetTokenByID retrieves a token by its UUID.
func (d *DB) GetTokenByID(ctx context.Context, id string) (Token, error) {
query := `
SELECT id, token, project_id, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
SELECT id, token, project_id, metadata, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
FROM tokens
WHERE id = ?
`

var token Token
var metadata sql.NullString
var expiresAt, lastUsedAt, deactivatedAt sql.NullTime
var maxRequests sql.NullInt32

err := d.QueryRowContextRebound(ctx, query, id).Scan(
&token.ID,
&token.Token,
&token.ProjectID,
&metadata,
&expiresAt,
&token.IsActive,
&deactivatedAt,
Expand All @@ -89,6 +93,9 @@ func (d *DB) GetTokenByID(ctx context.Context, id string) (Token, error) {
if expiresAt.Valid {
token.ExpiresAt = &expiresAt.Time
}
if metadata.Valid {
token.Metadata = &metadata.String
}
if lastUsedAt.Valid {
token.LastUsedAt = &lastUsedAt.Time
}
Expand All @@ -106,19 +113,21 @@ func (d *DB) GetTokenByID(ctx context.Context, id string) (Token, error) {
// GetTokenByToken retrieves a token by its token string (for authentication).
func (d *DB) GetTokenByToken(ctx context.Context, tokenString string) (Token, error) {
query := `
SELECT id, token, project_id, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
SELECT id, token, project_id, metadata, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
FROM tokens
WHERE token = ?
`

var token Token
var metadata sql.NullString
var expiresAt, lastUsedAt, deactivatedAt sql.NullTime
var maxRequests sql.NullInt32

err := d.QueryRowContextRebound(ctx, query, tokenString).Scan(
&token.ID,
&token.Token,
&token.ProjectID,
&metadata,
&expiresAt,
&token.IsActive,
&deactivatedAt,
Expand All @@ -138,6 +147,9 @@ func (d *DB) GetTokenByToken(ctx context.Context, tokenString string) (Token, er
if expiresAt.Valid {
token.ExpiresAt = &expiresAt.Time
}
if metadata.Valid {
token.Metadata = &metadata.String
}
if lastUsedAt.Valid {
token.LastUsedAt = &lastUsedAt.Time
}
Expand All @@ -162,12 +174,12 @@ func (d *DB) UpdateToken(ctx context.Context, token Token) error {

queryByID := `
UPDATE tokens
SET project_id = ?, expires_at = ?, is_active = ?, request_count = ?, max_requests = ?, last_used_at = ?
SET project_id = ?, metadata = ?, expires_at = ?, is_active = ?, request_count = ?, max_requests = ?, last_used_at = ?
WHERE id = ?
`
queryByToken := `
UPDATE tokens
SET project_id = ?, expires_at = ?, is_active = ?, request_count = ?, max_requests = ?, last_used_at = ?
SET project_id = ?, metadata = ?, expires_at = ?, is_active = ?, request_count = ?, max_requests = ?, last_used_at = ?
WHERE token = ?
`

Expand All @@ -182,6 +194,7 @@ func (d *DB) UpdateToken(ctx context.Context, token Token) error {
ctx,
query,
token.ProjectID,
token.Metadata,
token.ExpiresAt,
token.IsActive,
token.RequestCount,
Expand Down Expand Up @@ -232,7 +245,7 @@ func (d *DB) DeleteToken(ctx context.Context, tokenID string) error {
// ListTokens retrieves all tokens from the database.
func (d *DB) ListTokens(ctx context.Context) ([]Token, error) {
query := `
SELECT id, token, project_id, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
SELECT id, token, project_id, metadata, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
FROM tokens
ORDER BY created_at DESC
`
Expand All @@ -243,7 +256,7 @@ func (d *DB) ListTokens(ctx context.Context) ([]Token, error) {
// GetTokensByProjectID retrieves all tokens for a project.
func (d *DB) GetTokensByProjectID(ctx context.Context, projectID string) ([]Token, error) {
query := `
SELECT id, token, project_id, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
SELECT id, token, project_id, metadata, expires_at, is_active, deactivated_at, request_count, max_requests, created_at, last_used_at, cache_hit_count
FROM tokens
WHERE project_id = ?
ORDER BY created_at DESC
Expand Down Expand Up @@ -419,13 +432,15 @@ func (d *DB) queryTokens(ctx context.Context, query string, args ...interface{})
var tokens []Token
for rows.Next() {
var token Token
var metadata sql.NullString
var expiresAt, lastUsedAt, deactivatedAt sql.NullTime
var maxRequests sql.NullInt32

if err := rows.Scan(
&token.ID,
&token.Token,
&token.ProjectID,
&metadata,
&expiresAt,
&token.IsActive,
&deactivatedAt,
Expand All @@ -441,6 +456,9 @@ func (d *DB) queryTokens(ctx context.Context, query string, args ...interface{})
if expiresAt.Valid {
token.ExpiresAt = &expiresAt.Time
}
if metadata.Valid {
token.Metadata = &metadata.String
}
if lastUsedAt.Valid {
token.LastUsedAt = &lastUsedAt.Time
}
Expand Down Expand Up @@ -533,10 +551,12 @@ func (a *DBTokenStoreAdapter) GetTokensByProjectID(ctx context.Context, projectI

// ImportTokenData and ExportTokenData helpers
func ImportTokenData(td token.TokenData) Token {
metadata := marshalTokenMetadata(td.Metadata)
return Token{
ID: td.ID,
Token: td.Token,
ProjectID: td.ProjectID,
Metadata: metadata,
ExpiresAt: td.ExpiresAt,
IsActive: td.IsActive,
DeactivatedAt: td.DeactivatedAt,
Expand All @@ -553,6 +573,7 @@ func ExportTokenData(t Token) token.TokenData {
ID: t.ID,
Token: t.Token,
ProjectID: t.ProjectID,
Metadata: unmarshalTokenMetadata(t.Metadata),
ExpiresAt: t.ExpiresAt,
IsActive: t.IsActive,
DeactivatedAt: t.DeactivatedAt,
Expand All @@ -564,6 +585,37 @@ func ExportTokenData(t Token) token.TokenData {
}
}

func marshalTokenMetadata(metadata map[string]string) *string {
if len(metadata) == 0 {
return nil
}

encoded, err := json.Marshal(metadata)
if err != nil {
return nil
}

result := string(encoded)
return &result
}

func unmarshalTokenMetadata(metadataJSON *string) map[string]string {
if metadataJSON == nil || *metadataJSON == "" {
return nil
}

var metadata map[string]string
if err := json.Unmarshal([]byte(*metadataJSON), &metadata); err != nil {
return nil
}

if len(metadata) == 0 {
return nil
}

return metadata
}

// --- RevocationStore interface implementation ---

// RevokeToken disables a token by setting is_active to false and deactivated_at to current time
Expand Down
8 changes: 8 additions & 0 deletions internal/database/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func TestTokenCRUD(t *testing.T) {
token1 := Token{
Token: "test-token-1",
ProjectID: project.ID,
Metadata: stringPtr(`{"feature":"sofabuddy","user_id":"42"}`),
ExpiresAt: &expiresAt,
IsActive: true,
RequestCount: 0,
Expand Down Expand Up @@ -83,6 +84,9 @@ func TestTokenCRUD(t *testing.T) {
if retrievedToken1.ProjectID != token1.ProjectID {
t.Fatalf("Expected project ID %s, got %s", token1.ProjectID, retrievedToken1.ProjectID)
}
if retrievedToken1.Metadata == nil || *retrievedToken1.Metadata != *token1.Metadata {
t.Fatalf("Expected metadata %v, got %v", token1.Metadata, retrievedToken1.Metadata)
}
if retrievedToken1.ExpiresAt == nil {
t.Fatalf("Expected expiration time, got nil")
}
Expand Down Expand Up @@ -239,6 +243,10 @@ func TestTokenCRUD(t *testing.T) {
}
}

func stringPtr(value string) *string {
return &value
}

func TestIncrementTokenUsageBatch(t *testing.T) {
db, cleanup := testDB(t)
defer cleanup()
Expand Down
Loading
Loading