diff --git a/README.md b/README.md index 04526f7..746165d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Infrastructure: `seon-labs/selfheal-lab-terraform`. Runtime configuration (compo | Directory | Runs on | Image | |---|---|---| | `legacy-api/` | GCP MIG instances (docker compose) | Artifact Registry `asia-northeast1-docker.pkg.dev/cms-d-414903/selfheal/legacy-api:` | -| `demo-app/` | AWS Kubernetes (Argo CD) | ECR `selfheal/demo-app` (added in WP-14) | +| `demo-app/` | AWS Kubernetes (Argo CD, namespace demo) | Public Artifact Registry `asia-northeast1-docker.pkg.dev/cms-d-414903/selfheal-public/demo-api:` (anonymous pull; kubeadm nodes have no cloud credentials) | Build (Cloud Build, from the repository root): diff --git a/demo-app/Dockerfile b/demo-app/Dockerfile new file mode 100644 index 0000000..5d6319f --- /dev/null +++ b/demo-app/Dockerfile @@ -0,0 +1,15 @@ +# demo-app api (Kubernetes layer of the self-healing lab). Built with Cloud Build into the public Artifact Registry repo. +FROM golang:1.27.1-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-mod=mod +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -trimpath -ldflags="-s -w" -o /out/demo-api . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app +USER 10001 +COPY --from=build /out/demo-api /usr/local/bin/demo-api +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/demo-api"] diff --git a/demo-app/go.mod b/demo-app/go.mod new file mode 100644 index 0000000..93fa8d9 --- /dev/null +++ b/demo-app/go.mod @@ -0,0 +1,43 @@ +module github.com/selfheal-lab/selfheal-lab-apps/demo-app + +go 1.27 + +require ( + github.com/jackc/pgx/v5 v5.11.0 + github.com/prometheus/client_golang v1.24.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/demo-app/go.sum b/demo-app/go.sum new file mode 100644 index 0000000..2c6868a --- /dev/null +++ b/demo-app/go.sum @@ -0,0 +1,96 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/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/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/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.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +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/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/demo-app/main.go b/demo-app/main.go new file mode 100644 index 0000000..7732c05 --- /dev/null +++ b/demo-app/main.go @@ -0,0 +1,242 @@ +// demo-app api: the "container layer" service of the self-healing lab (experiment design 3.1). +// GET /healthz process liveness +// GET /readyz DB ping + legacy-api /healthz (readiness) +// GET /api/orders recent orders (DB read) +// POST /api/orders {"item":"apple","qty":2} (DB write) +// GET /api/price/{item} proxied from legacy-api via the ILB (LEGACY_URL, LEGACY_TIMEOUT) +// GET /metrics Prometheus exposition (demo_http_*) +// Traces go to OTEL_EXPORTER_OTLP_ENDPOINT (Alloy) when set. +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +var ( + reqTotal = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "demo_http_requests_total", Help: "HTTP requests by route and status"}, []string{"route", "status"}) + reqDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "demo_http_request_duration_seconds", Help: "HTTP request latency", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5}}, []string{"route"}) + legacyDur = prometheus.NewHistogram(prometheus.HistogramOpts{Name: "demo_legacy_request_duration_seconds", Help: "Upstream legacy-api latency", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5}}) +) + +type statusWriter struct { + http.ResponseWriter + code int +} + +func (w *statusWriter) WriteHeader(c int) { w.code = c; w.ResponseWriter.WriteHeader(c) } + +func instrument(route string, h http.HandlerFunc) http.Handler { + return otelhttp.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, code: 200} + start := time.Now() + h(sw, r) + reqDur.WithLabelValues(route).Observe(time.Since(start).Seconds()) + reqTotal.WithLabelValues(route, strconv.Itoa(sw.code)).Inc() + }), route) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func env(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +func setupTracing(ctx context.Context) func(context.Context) error { + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" { + return func(context.Context) error { return nil } + } + exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithInsecure()) + if err != nil { + log.Printf("otlp exporter: %v (tracing disabled)", err) + return func(context.Context) error { return nil } + } + res, _ := resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("demo-api"))) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp), sdktrace.WithResource(res)) + otel.SetTracerProvider(tp) + return tp.Shutdown +} + +func main() { + prometheus.MustRegister(reqTotal, reqDur, legacyDur) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + shutdownTracing := setupTracing(ctx) + defer func() { _ = shutdownTracing(context.Background()) }() + + dsn := os.Getenv("DB_DSN") + var db *sql.DB + if dsn != "" { + var err error + if db, err = sql.Open("pgx", dsn); err != nil { + log.Printf("db open: %v", err) + db = nil + } else { + db.SetMaxOpenConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + } + } else { + log.Printf("DB_DSN not set") + } + legacyURL := env("LEGACY_URL", "http://10.20.0.100:8080") + legacyTimeout, err := time.ParseDuration(env("LEGACY_TIMEOUT", "2s")) + if err != nil { + legacyTimeout = 2 * time.Second + } + legacy := &http.Client{Timeout: legacyTimeout, Transport: otelhttp.NewTransport(http.DefaultTransport)} + + mux := http.NewServeMux() + mux.Handle("/healthz", instrument("healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + })) + mux.Handle("/readyz", instrument("readyz", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + problems := map[string]string{} + if db == nil { + problems["db"] = "not configured" + } else if err := db.PingContext(ctx); err != nil { + problems["db"] = err.Error() + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, legacyURL+"/healthz", nil) + if resp, err := legacy.Do(req); err != nil { + problems["legacy"] = err.Error() + } else { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + problems["legacy"] = "status " + strconv.Itoa(resp.StatusCode) + } + } + if len(problems) > 0 { + writeJSON(w, 503, map[string]any{"ready": false, "problems": problems}) + return + } + writeJSON(w, 200, map[string]any{"ready": true}) + })) + mux.Handle("/api/orders", instrument("orders", func(w http.ResponseWriter, r *http.Request) { + if db == nil { + writeJSON(w, 503, map[string]string{"error": "db unavailable"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + switch r.Method { + case http.MethodGet: + rows, err := db.QueryContext(ctx, "SELECT id, item, qty, status, created_at FROM app.orders ORDER BY id DESC LIMIT 20") + if err != nil { + log.Printf("orders query: %v", err) + writeJSON(w, 503, map[string]string{"error": "db unavailable"}) + return + } + defer rows.Close() + type order struct { + ID int64 `json:"id"` + Item string `json:"item"` + Qty int `json:"qty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + } + out := []order{} + for rows.Next() { + var o order + if err := rows.Scan(&o.ID, &o.Item, &o.Qty, &o.Status, &o.CreatedAt); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + out = append(out, o) + } + writeJSON(w, 200, map[string]any{"orders": out, "count": len(out)}) + case http.MethodPost: + var in struct { + Item string `json:"item"` + Qty int `json:"qty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&in); err != nil || in.Item == "" || in.Qty <= 0 { + writeJSON(w, 400, map[string]string{"error": "item and positive qty required"}) + return + } + var id int64 + err := db.QueryRowContext(ctx, "INSERT INTO app.orders (item, qty) VALUES ($1, $2) RETURNING id", in.Item, in.Qty).Scan(&id) + if err != nil { + if strings.Contains(err.Error(), "violates foreign key") { + writeJSON(w, 404, map[string]string{"error": "unknown item", "item": in.Item}) + return + } + log.Printf("orders insert: %v", err) + writeJSON(w, 503, map[string]string{"error": "db unavailable"}) + return + } + writeJSON(w, 201, map[string]any{"id": id, "item": in.Item, "qty": in.Qty, "status": "new"}) + default: + writeJSON(w, 405, map[string]string{"error": "method not allowed"}) + } + })) + mux.Handle("/api/price/", instrument("price", func(w http.ResponseWriter, r *http.Request) { + item := strings.TrimPrefix(r.URL.Path, "/api/price/") + if item == "" || strings.Contains(item, "/") { + writeJSON(w, 404, map[string]string{"error": "item required"}) + return + } + req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, legacyURL+"/v1/price/"+item, nil) + start := time.Now() + resp, err := legacy.Do(req) + legacyDur.Observe(time.Since(start).Seconds()) + if err != nil { + code := 502 + if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) { + code = 504 + } + writeJSON(w, code, map[string]string{"error": "legacy-api unreachable", "detail": err.Error()}) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + })) + mux.Handle("/metrics", promhttp.Handler()) + + srv := &http.Server{Addr: ":" + env("PORT", "8080"), Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { + <-ctx.Done() + sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + }() + log.Printf("demo-api listening on %s (legacy=%s timeout=%s)", srv.Addr, legacyURL, legacyTimeout) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +}