From 9cc052c162657ec9b13bb3d6a7637b8c1b80676b Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 14 Apr 2026 10:31:56 +0200 Subject: [PATCH] Add telemetry --- .gitignore | 2 + config.example.yaml | 7 ++ flake.nix | 3 + go.mod | 53 +++++++++--- go.sum | 111 ++++++++++++++++++------ internal/config/config.go | 24 ++++-- internal/logging/logging.go | 25 ++++-- internal/proxy/handler.go | 148 ++++++++++++++++++++++++++++++-- internal/proxy/upstream.go | 9 ++ internal/server/server.go | 4 + internal/telemetry/logbridge.go | 81 +++++++++++++++++ internal/telemetry/metrics.go | 50 +++++++++++ internal/telemetry/telemetry.go | 107 +++++++++++++++++++++++ main.go | 16 +++- package.nix | 2 +- 15 files changed, 580 insertions(+), 62 deletions(-) create mode 100644 internal/telemetry/logbridge.go create mode 100644 internal/telemetry/metrics.go create mode 100644 internal/telemetry/telemetry.go diff --git a/.gitignore b/.gitignore index 2863471..23ca70a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ anthropic-proxy result config.yaml + +vendor/** diff --git a/config.example.yaml b/config.example.yaml index 5335a66..797e639 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,5 +1,12 @@ port: 8082 +# telemetry: +# endpoint: "localhost:4317" # OTLP gRPC endpoint (omit to disable export) +# insecure: true # disable TLS for local dev +# service_name: "anthropic-proxy" +# headers: # optional auth headers (e.g. Grafana Cloud) +# Authorization: "Basic ..." + logging: level: debug file: /home/fujin/.local/log/anthropic-proxy.log diff --git a/flake.nix b/flake.nix index 9c5e9e6..ab2cbcb 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,9 @@ shellHook = '' export GOPATH="$PWD/.go" export PATH="$GOPATH/bin:$PATH" + + export ANTHROPIC_BASE_URL=http://localhost:8082 + export ANTHROPIC_API_KEY=sk-cliproxy-fujin ''; }; } diff --git a/go.mod b/go.mod index e1880b6..e76d812 100644 --- a/go.mod +++ b/go.mod @@ -5,23 +5,45 @@ go 1.26 require ( github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 + github.com/refraction-networking/utls v1.8.2 + github.com/rs/zerolog v1.35.0 github.com/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/log v0.19.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/log v0.19.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + golang.org/x/net v0.52.0 + google.golang.org/grpc v1.80.0 + gopkg.in/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/BurntSushi/toml v1.6.0 // indirect github.com/andybalholm/brotli v1.0.6 // indirect - github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -30,22 +52,25 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect - github.com/rs/zerolog v1.35.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - golang.org/x/arch v0.22.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/arch v0.25.0 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/lumberjack.v2 v2.0.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 6c54977..4ad9016 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,54 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= @@ -55,8 +70,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -65,8 +80,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -95,34 +110,74 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= +golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/lumberjack.v2 v2.0.0 h1:IDj6hi8KbNiPQ5VaYNFZ7dBJLF5LFeKvsFrWHjA5aq4= gopkg.in/lumberjack.v2 v2.0.0/go.mod h1:bp5nQ2kK/lLQSmTk29azj9+JB6bWci56xFn/lvd5GLI= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 78f022e..718908d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,11 +11,12 @@ import ( ) type Config struct { - Port int `yaml:"port"` - APIKeys []string `yaml:"api_keys"` - ClaudeBinary string `yaml:"claude_binary"` - Sanitize SanitizeConfig `yaml:"sanitize"` - Logging LoggingConfig `yaml:"logging"` + Port int `yaml:"port"` + APIKeys []string `yaml:"api_keys"` + ClaudeBinary string `yaml:"claude_binary"` + Sanitize SanitizeConfig `yaml:"sanitize"` + Logging LoggingConfig `yaml:"logging"` + Telemetry TelemetryConfig `yaml:"telemetry"` } type SanitizeConfig struct { @@ -34,6 +35,15 @@ type ReplaceRule struct { Replace string `yaml:"replace"` } +type TelemetryConfig struct { + Endpoint string `yaml:"endpoint"` + Insecure bool `yaml:"insecure"` + ServiceName string `yaml:"service_name"` + Headers map[string]string `yaml:"headers"` +} + +func (t TelemetryConfig) ExportEnabled() bool { return t.Endpoint != "" } + type LoggingConfig struct { Level string `yaml:"level"` File string `yaml:"file"` @@ -76,6 +86,10 @@ func Load(path string) (*Config, error) { cfg.Logging.MaxAgeDays = 30 } + if cfg.Telemetry.ServiceName == "" { + cfg.Telemetry.ServiceName = "anthropic-proxy" + } + // Check for deprecated claude_credentials field var rawCfg map[string]interface{} if err := yaml.Unmarshal(data, &rawCfg); err == nil { diff --git a/internal/logging/logging.go b/internal/logging/logging.go index c2401ca..f378d7f 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -3,6 +3,7 @@ package logging import ( "context" "encoding/json" + "io" "net/http" "os" "strings" @@ -28,7 +29,9 @@ type Config struct { // - File set: JSON → lumberjack rotating file // - File empty + TTY: colored ConsoleWriter → stderr // - File empty + not TTY: JSON → stderr (for systemd journal) -func Setup(cfg Config) zerolog.Logger { +// Extra writers (e.g., OTLP log bridge) are added via io.MultiWriter so logs +// are written to both the primary destination and any extra writers. +func Setup(cfg Config, extraWriters ...io.Writer) zerolog.Logger { // Parse log level level, err := zerolog.ParseLevel(cfg.Level) if err != nil || cfg.Level == "" { @@ -48,20 +51,32 @@ func Setup(cfg Config) zerolog.Logger { MaxAge: cfg.MaxAgeDays, Compress: cfg.Compress, } - logger = zerolog.New(jack).With().Timestamp().Caller().Logger() + var w io.Writer = jack + if len(extraWriters) > 0 { + w = io.MultiWriter(append([]io.Writer{jack}, extraWriters...)...) + } + logger = zerolog.New(w).With().Timestamp().Caller().Logger() } else { fi, err := os.Stderr.Stat() isTTY := err == nil && (fi.Mode()&os.ModeCharDevice) != 0 if isTTY { - // Dev mode: colored console + // Dev mode: colored console (extra writers get JSON, console gets pretty) cw := zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: time.RFC3339, } - logger = zerolog.New(cw).With().Timestamp().Caller().Logger() + var w io.Writer = cw + if len(extraWriters) > 0 { + w = io.MultiWriter(append([]io.Writer{cw}, extraWriters...)...) + } + logger = zerolog.New(w).With().Timestamp().Caller().Logger() } else { // Systemd journal: JSON to stderr - logger = zerolog.New(os.Stderr).With().Timestamp().Caller().Logger() + var w io.Writer = os.Stderr + if len(extraWriters) > 0 { + w = io.MultiWriter(append([]io.Writer{os.Stderr}, extraWriters...)...) + } + logger = zerolog.New(w).With().Timestamp().Caller().Logger() } } diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index 274b442..caaff03 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -9,9 +9,12 @@ import ( "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "github.com/tidwall/gjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "github.com/fujin/anthropic-proxy/internal/auth" "github.com/fujin/anthropic-proxy/internal/logging" + "github.com/fujin/anthropic-proxy/internal/telemetry" ) func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func() *Sanitizer) gin.HandlerFunc { @@ -55,10 +58,16 @@ func HandleMessages(pool *auth.Pool, profile *SniffedProfile, getSanitizer func( func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte) { startTime := time.Now() - respBody, headers, statusCode, err := upstream.Execute(c.Request.Context(), cred, body) + model := gjson.GetBytes(body, "model").String() + ctx := c.Request.Context() + + telemetry.RequestBodySize.Record(ctx, int64(len(body)), + metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", false))) + + respBody, headers, statusCode, err := upstream.Execute(ctx, cred, body) + latencyMs := float64(time.Since(startTime).Milliseconds()) + if err != nil { - latencyMs := float64(time.Since(startTime).Milliseconds()) - model := gjson.GetBytes(body, "model").String() log.Error(). Err(err). Str("credential", cred.Email). @@ -69,14 +78,39 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p Int("request_body_size", len(body)). Float64("latency_ms", latencyMs). Msg("upstream connection error") + + telemetry.UpstreamErrors.Add(ctx, 1, + metric.WithAttributes( + attribute.String("error_type", "connection"), + attribute.String("credential", cred.Email), + attribute.Int("status_code", http.StatusBadGateway), + )) + telemetry.RequestCounter.Add(ctx, 1, + metric.WithAttributes( + attribute.String("model", model), + attribute.Bool("stream", false), + attribute.Int("status_code", http.StatusBadGateway), + )) + telemetry.RequestDuration.Record(ctx, latencyMs, + metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", false), attribute.Int("status_code", http.StatusBadGateway))) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"}) return } + attrs := []attribute.KeyValue{ + attribute.String("model", model), + attribute.Bool("stream", false), + attribute.Int("status_code", statusCode), + } + + telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...)) + telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...)) + if statusCode >= 400 { pool.MarkFailure(cred, statusCode) - latencyMs := float64(time.Since(startTime).Milliseconds()) - model := gjson.GetBytes(body, "model").String() + telemetry.CredentialCooldowns.Add(ctx, 1, + metric.WithAttributes(attribute.Int("status_code", statusCode))) errorType := gjson.GetBytes(respBody, "error.type").String() errorMessage := gjson.GetBytes(respBody, "error.message").String() log.Error(). @@ -94,9 +128,33 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p Int("request_body_size", len(body)). Str("request_headers", logging.RedactHeaders(c.Request.Header)). Msg("upstream error") + + telemetry.UpstreamErrors.Add(ctx, 1, + metric.WithAttributes( + attribute.Int("status_code", statusCode), + attribute.String("error_type", errorType), + attribute.String("credential", cred.Email), + )) } else { pool.MarkSuccess(cred) respBody = san.DesanitizeResponse(respBody) + + inputTokens := gjson.GetBytes(respBody, "usage.input_tokens").Int() + outputTokens := gjson.GetBytes(respBody, "usage.output_tokens").Int() + tokenAttrs := metric.WithAttributes( + attribute.String("model", model), + attribute.String("credential", cred.Email), + ) + telemetry.TokensInput.Add(ctx, inputTokens, tokenAttrs) + telemetry.TokensOutput.Add(ctx, outputTokens, tokenAttrs) + + log.Info(). + Int("status", statusCode). + Float64("latency_ms", latencyMs). + Str("model", model). + Int64("input_tokens", inputTokens). + Int64("output_tokens", outputTokens). + Msg("request completed") } for _, h := range []string{"Content-Type", "X-Request-Id"} { @@ -110,10 +168,16 @@ func handleNonStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, p func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool *auth.Pool, cred *auth.Credential, body []byte, originalBody []byte) { startTime := time.Now() - resp, err := upstream.ExecuteStream(c.Request.Context(), cred, body) + model := gjson.GetBytes(body, "model").String() + ctx := c.Request.Context() + + telemetry.StreamRequests.Add(ctx, 1, metric.WithAttributes(attribute.String("model", model))) + telemetry.RequestBodySize.Record(ctx, int64(len(body)), + metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", true))) + + resp, err := upstream.ExecuteStream(ctx, cred, body) if err != nil { latencyMs := float64(time.Since(startTime).Milliseconds()) - model := gjson.GetBytes(body, "model").String() log.Error(). Err(err). Str("credential", cred.Email). @@ -124,6 +188,22 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool Int("request_body_size", len(body)). Float64("latency_ms", latencyMs). Msg("upstream connection error") + + telemetry.UpstreamErrors.Add(ctx, 1, + metric.WithAttributes( + attribute.String("error_type", "connection"), + attribute.String("credential", cred.Email), + attribute.Int("status_code", http.StatusBadGateway), + )) + telemetry.RequestCounter.Add(ctx, 1, + metric.WithAttributes( + attribute.String("model", model), + attribute.Bool("stream", true), + attribute.Int("status_code", http.StatusBadGateway), + )) + telemetry.RequestDuration.Record(ctx, latencyMs, + metric.WithAttributes(attribute.String("model", model), attribute.Bool("stream", true), attribute.Int("status_code", http.StatusBadGateway))) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream stream request failed"}) return } @@ -131,9 +211,10 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool if resp.StatusCode >= 400 { pool.MarkFailure(cred, resp.StatusCode) + telemetry.CredentialCooldowns.Add(ctx, 1, + metric.WithAttributes(attribute.Int("status_code", resp.StatusCode))) respBody, _ := io.ReadAll(resp.Body) latencyMs := float64(time.Since(startTime).Milliseconds()) - model := gjson.GetBytes(body, "model").String() errorType := gjson.GetBytes(respBody, "error.type").String() errorMessage := gjson.GetBytes(respBody, "error.message").String() log.Error(). @@ -151,6 +232,21 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool Int("request_body_size", len(body)). Str("request_headers", logging.RedactHeaders(c.Request.Header)). Msg("upstream error") + + attrs := []attribute.KeyValue{ + attribute.String("model", model), + attribute.Bool("stream", true), + attribute.Int("status_code", resp.StatusCode), + } + telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...)) + telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...)) + telemetry.UpstreamErrors.Add(ctx, 1, + metric.WithAttributes( + attribute.Int("status_code", resp.StatusCode), + attribute.String("error_type", errorType), + attribute.String("credential", cred.Email), + )) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody) return } @@ -169,14 +265,50 @@ func handleStream(c *gin.Context, upstream *UpstreamClient, san *Sanitizer, pool return } + var inputTokens, outputTokens int64 scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := san.DesanitizeStreamEvent(scanner.Text()) c.Writer.WriteString(line + "\n") flusher.Flush() + + // Extract token usage from message_delta event + if len(line) > 5 && line[:5] == "data:" { + data := line[5:] + if gjson.Get(data, "type").String() == "message_delta" { + inputTokens = gjson.Get(data, "usage.input_tokens").Int() + outputTokens = gjson.Get(data, "usage.output_tokens").Int() + } + } } + latencyMs := float64(time.Since(startTime).Milliseconds()) + attrs := []attribute.KeyValue{ + attribute.String("model", model), + attribute.Bool("stream", true), + attribute.Int("status_code", http.StatusOK), + } + telemetry.RequestCounter.Add(ctx, 1, metric.WithAttributes(attrs...)) + telemetry.RequestDuration.Record(ctx, latencyMs, metric.WithAttributes(attrs...)) + + if inputTokens > 0 || outputTokens > 0 { + tokenAttrs := metric.WithAttributes( + attribute.String("model", model), + attribute.String("credential", cred.Email), + ) + telemetry.TokensInput.Add(ctx, inputTokens, tokenAttrs) + telemetry.TokensOutput.Add(ctx, outputTokens, tokenAttrs) + } + + log.Info(). + Float64("latency_ms", latencyMs). + Str("model", model). + Bool("stream", true). + Int64("input_tokens", inputTokens). + Int64("output_tokens", outputTokens). + Msg("stream completed") + if err := scanner.Err(); err != nil { log.Error().Err(err).Msg("stream scan error") } diff --git a/internal/proxy/upstream.go b/internal/proxy/upstream.go index 3086efd..4af1a0f 100644 --- a/internal/proxy/upstream.go +++ b/internal/proxy/upstream.go @@ -51,6 +51,15 @@ func (u *UpstreamClient) applyHeaders(req *http.Request, token string, streaming req.Header.Del("x-api-key") if strings.HasPrefix(token, "sk-ant-oat") { req.Header.Set("Authorization", "Bearer "+token) + // OAuth tokens require this beta flag — without it the API rejects with 401 + existing := req.Header.Get("anthropic-beta") + if !strings.Contains(existing, "oauth-2025-04-20") { + if existing == "" { + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + } else { + req.Header.Set("anthropic-beta", existing+",oauth-2025-04-20") + } + } } else { req.Header.Set("x-api-key", token) } diff --git a/internal/server/server.go b/internal/server/server.go index 708c044..64c097c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "github.com/fujin/anthropic-proxy/internal/config" "github.com/fujin/anthropic-proxy/internal/logging" "github.com/fujin/anthropic-proxy/internal/proxy" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) type Server struct { @@ -37,6 +38,9 @@ func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Se engine := gin.New() engine.Use(gin.Recovery()) engine.Use(corsMiddleware()) + if cfg.Telemetry.ExportEnabled() { + engine.Use(otelgin.Middleware(cfg.Telemetry.ServiceName)) + } engine.Use(s.authMiddleware()) engine.Use(logging.GinRequestLogger()) diff --git a/internal/telemetry/logbridge.go b/internal/telemetry/logbridge.go new file mode 100644 index 0000000..321260a --- /dev/null +++ b/internal/telemetry/logbridge.go @@ -0,0 +1,81 @@ +package telemetry + +import ( + "context" + "encoding/json" + "time" + + otellog "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +// LogBridge implements io.Writer and forwards zerolog JSON lines to the +// OTel LoggerProvider. It is used as an extra writer in zerolog's MultiWriter +// so that logs go to both file and OTLP. +type LogBridge struct { + provider *sdklog.LoggerProvider +} + +func (b *LogBridge) Write(p []byte) (n int, err error) { + var entry map[string]interface{} + if err := json.Unmarshal(p, &entry); err != nil { + return len(p), nil // skip malformed lines + } + + logger := b.provider.Logger("zerolog") + + var rec otellog.Record + rec.SetTimestamp(time.Now()) + + if msg, ok := entry["message"].(string); ok { + rec.SetBody(otellog.StringValue(msg)) + } + + if lvl, ok := entry["level"].(string); ok { + rec.SetSeverity(mapSeverity(lvl)) + } + + // Forward all fields as attributes + attrs := make([]otellog.KeyValue, 0, len(entry)) + for k, v := range entry { + if k == "message" || k == "level" || k == "time" { + continue + } + switch val := v.(type) { + case string: + attrs = append(attrs, otellog.String(k, val)) + case float64: + attrs = append(attrs, otellog.Float64(k, val)) + case bool: + attrs = append(attrs, otellog.Bool(k, val)) + default: + b, _ := json.Marshal(val) + attrs = append(attrs, otellog.String(k, string(b))) + } + } + rec.AddAttributes(attrs...) + + logger.Emit(context.Background(), rec) + return len(p), nil +} + +func mapSeverity(level string) otellog.Severity { + switch level { + case "trace": + return otellog.SeverityTrace + case "debug": + return otellog.SeverityDebug + case "info": + return otellog.SeverityInfo + case "warn", "warning": + return otellog.SeverityWarn + case "error": + return otellog.SeverityError + case "fatal": + return otellog.SeverityFatal + case "panic": + return otellog.SeverityFatal2 + default: + return otellog.SeverityInfo + } +} diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go new file mode 100644 index 0000000..1f7abd6 --- /dev/null +++ b/internal/telemetry/metrics.go @@ -0,0 +1,50 @@ +package telemetry + +import ( + "go.opentelemetry.io/otel/metric" +) + +var ( + RequestCounter metric.Int64Counter + RequestDuration metric.Float64Histogram + RequestBodySize metric.Int64Histogram + UpstreamErrors metric.Int64Counter + TokensInput metric.Int64Counter + TokensOutput metric.Int64Counter + CredentialCooldowns metric.Int64Counter + ActiveCredentials metric.Int64UpDownCounter + StreamRequests metric.Int64Counter +) + +// InitMetrics creates all metric instruments from the given meter. +func InitMetrics(meter metric.Meter) { + RequestCounter, _ = meter.Int64Counter("proxy.request.count", + metric.WithDescription("Total proxy requests"), + ) + RequestDuration, _ = meter.Float64Histogram("proxy.request.duration_ms", + metric.WithDescription("Request latency in milliseconds"), + metric.WithUnit("ms"), + ) + RequestBodySize, _ = meter.Int64Histogram("proxy.request.body_size_bytes", + metric.WithDescription("Request body size in bytes"), + metric.WithUnit("By"), + ) + UpstreamErrors, _ = meter.Int64Counter("proxy.upstream.errors", + metric.WithDescription("Upstream error count"), + ) + TokensInput, _ = meter.Int64Counter("proxy.tokens.input", + metric.WithDescription("Input tokens consumed"), + ) + TokensOutput, _ = meter.Int64Counter("proxy.tokens.output", + metric.WithDescription("Output tokens consumed"), + ) + CredentialCooldowns, _ = meter.Int64Counter("proxy.credential.cooldowns", + metric.WithDescription("Credential cooldown activations"), + ) + ActiveCredentials, _ = meter.Int64UpDownCounter("proxy.credential.active", + metric.WithDescription("Currently active (non-cooldown) credentials"), + ) + StreamRequests, _ = meter.Int64Counter("proxy.stream.requests", + metric.WithDescription("Streaming request count"), + ) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..0e5f7c9 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,107 @@ +package telemetry + +import ( + "context" + "io" + + "github.com/fujin/anthropic-proxy/internal/config" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + otellog "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/sdk/log" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +// Setup initializes OpenTelemetry providers. It always creates a MeterProvider +// so metrics can be recorded in-process. When cfg.ExportEnabled(), OTLP gRPC +// exporters are additionally configured to push to the LGTM stack. +// Returns a shutdown function and an optional io.Writer for the log bridge. +func Setup(ctx context.Context, cfg config.TelemetryConfig) (shutdown func(context.Context) error, logWriter io.Writer, err error) { + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(cfg.ServiceName), + ), + ) + if err != nil { + return nil, nil, err + } + + if !cfg.ExportEnabled() { + // No export — set up in-memory meter provider only so metric + // instruments are valid (they just don't export anywhere). + mp := sdkmetric.NewMeterProvider(sdkmetric.WithResource(res)) + otel.SetMeterProvider(mp) + InitMetrics(mp.Meter(cfg.ServiceName)) + return func(ctx context.Context) error { return mp.Shutdown(ctx) }, nil, nil + } + + // Build exporter options + traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint)} + metricOpts := []otlpmetricgrpc.Option{ + otlpmetricgrpc.WithEndpoint(cfg.Endpoint), + otlpmetricgrpc.WithTemporalitySelector(sdkmetric.CumulativeTemporalitySelector), + } + logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(cfg.Endpoint)} + if cfg.Insecure { + traceOpts = append(traceOpts, otlptracegrpc.WithInsecure()) + metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure()) + logOpts = append(logOpts, otlploggrpc.WithInsecure()) + } + + // Trace exporter + traceExp, err := otlptracegrpc.New(ctx, traceOpts...) + if err != nil { + return nil, nil, err + } + tp := trace.NewTracerProvider( + trace.WithBatcher(traceExp), + trace.WithResource(res), + ) + otel.SetTracerProvider(tp) + + // Metric exporter + metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...) + if err != nil { + return nil, nil, err + } + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + InitMetrics(mp.Meter(cfg.ServiceName)) + + // Log exporter + logExp, err := otlploggrpc.New(ctx, logOpts...) + if err != nil { + return nil, nil, err + } + lp := log.NewLoggerProvider( + log.WithProcessor(log.NewBatchProcessor(logExp)), + log.WithResource(res), + ) + otellog.SetLoggerProvider(lp) + + bridge := &LogBridge{provider: lp} + + shutdownFn := func(ctx context.Context) error { + var firstErr error + if e := tp.Shutdown(ctx); e != nil && firstErr == nil { + firstErr = e + } + if e := mp.Shutdown(ctx); e != nil && firstErr == nil { + firstErr = e + } + if e := lp.Shutdown(ctx); e != nil && firstErr == nil { + firstErr = e + } + return firstErr + } + + return shutdownFn, bridge, nil +} diff --git a/main.go b/main.go index c95f887..eaffa31 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "net/http" "os" "os/signal" @@ -14,6 +15,7 @@ import ( "github.com/fujin/anthropic-proxy/internal/logging" "github.com/fujin/anthropic-proxy/internal/proxy" "github.com/fujin/anthropic-proxy/internal/server" + "github.com/fujin/anthropic-proxy/internal/telemetry" "github.com/rs/zerolog/log" ) @@ -23,6 +25,18 @@ func run() error { return fmt.Errorf("load config: %w", err) } + // Initialize telemetry (metrics always active; OTLP export when endpoint set) + telemetryShutdown, logBridge, err := telemetry.Setup(context.Background(), cfg.Telemetry) + if err != nil { + return fmt.Errorf("telemetry setup: %w", err) + } + defer telemetryShutdown(context.Background()) + + var extraWriters []io.Writer + if logBridge != nil { + extraWriters = append(extraWriters, logBridge) + } + logging.Setup(logging.Config{ Level: cfg.Logging.Level, File: cfg.Logging.File, @@ -30,7 +44,7 @@ func run() error { MaxBackups: cfg.Logging.MaxBackups, MaxAgeDays: cfg.Logging.MaxAgeDays, Compress: cfg.Logging.Compress, - }) + }, extraWriters...) // Load credentials from ~/.claude/.credentials.json creds, err := config.LoadDefaultCredentials() diff --git a/package.nix b/package.nix index 3a63f29..9d3df46 100644 --- a/package.nix +++ b/package.nix @@ -11,7 +11,7 @@ buildGoModule rec { src = ./.; - vendorHash = "sha256-xKztaGlelw7OI/6RJkkepHmLLH+dCCqYXE71C+y3PwI="; + vendorHash = "sha256-8pq4GYFjOfYcYLcZSuXMWn77RUxVGP18AcyzIJGbKf4="; meta = with lib; { description = "Reverse proxy that lets OpenCode (and similar tools) use a Claude subscription instead of an API key.";