From 4ffbb12e62c7f6f551f88ee8d18347ae19c809cc Mon Sep 17 00:00:00 2001 From: karthikeyangs9 Date: Fri, 1 May 2026 22:23:55 +0530 Subject: [PATCH 1/2] [gin-dynamodb-valkey] demonstrate ctx propagation, SQS poller, outbound HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor example to show the patterns required for spans to link into one trace per request: - Service struct holds clients only; methods take ctx context.Context as first arg (no struct-cached ctx — the most common cause of orphan span trees in real customer code). - Custom internal spans via tracer.Start(ctx, "Service.X") so handler-side business logic appears as children of the gin SERVER span. - Outbound HTTP via httpagent.NewClient + http.NewRequestWithContext — emits CLIENT spans nested under ctx AND injects W3C traceparent for cross-service trace continuation. - SQS long-poll worker with trace.WithNewRoot() per iteration and a long-lived ctx (signal.NotifyContext on SIGINT/SIGTERM) so polls aren't cancelled mid-flight and recorded as STATUS_CODE_ERROR. - Graceful shutdown via http.Server.Shutdown. README adds a Context Propagation note linking to the product integration doc for the full pattern guide. Removed stale go.mod.new. --- go/gin-dynamodb-valkey/README.md | 9 +- go/gin-dynamodb-valkey/go.mod | 9 +- go/gin-dynamodb-valkey/go.mod.new | 15 -- go/gin-dynamodb-valkey/go.sum | 6 + go/gin-dynamodb-valkey/main.go | 253 ++++++++++++++++++++++++------ 5 files changed, 221 insertions(+), 71 deletions(-) delete mode 100644 go/gin-dynamodb-valkey/go.mod.new diff --git a/go/gin-dynamodb-valkey/README.md b/go/gin-dynamodb-valkey/README.md index 44b1067..6081351 100644 --- a/go/gin-dynamodb-valkey/README.md +++ b/go/gin-dynamodb-valkey/README.md @@ -1,6 +1,6 @@ # Gin + DynamoDB + Valkey -Go/Gin service instrumented with the Last9 Go Agent — emits distributed traces for Amazon DynamoDB (via `otelaws`) and Valkey (via `valkeyotel`). +Go/Gin service instrumented with the Last9 Go Agent — emits distributed traces for Amazon DynamoDB (via `otelaws`), Valkey (via `valkeyotel`), SQS long-poll workers, and outbound HTTP (via `httpagent`). Demonstrates `context.Context` propagation end-to-end so spans link into one tree per request. ## Prerequisites @@ -49,8 +49,13 @@ Go/Gin service instrumented with the Last9 Go Agent — emits distributed traces curl -XPOST "http://localhost:8080/cache/hello?value=world" curl http://localhost:8080/cache/hello curl http://localhost:8080/users/u1 + curl "http://localhost:8080/external?url=https://httpbin.org/get" ``` +## Context propagation + +`main.go` threads `context.Context` from each gin handler through `Service` methods to every SDK call (DynamoDB, Valkey, SQS, outbound HTTP). This is what makes spans nest into a single trace per request. See the [Go Gin integration guide](https://last9.io/docs/integrations/golang-gin) for the full pattern + anti-patterns. + ## Configuration Replace every `` with your own values. `local`-only fields (marked with *) are for the docker-compose flow and should be removed in production. @@ -66,6 +71,7 @@ Replace every `` with your own values. `local`-only fields (marked | `AWS_ENDPOINT_URL`* | `http://localhost:8000` | DynamoDB-local only. **Omit in production.** | | `AWS_ACCESS_KEY_ID`* / `AWS_SECRET_ACCESS_KEY`* | `local` / `local` | DynamoDB-local only. Use IAM role / instance profile in production. | | `DYNAMODB_TABLE` | `` | Defaults to `users` if unset | +| `SQS_QUEUE_URL` | `` | Optional. When set, starts the SQS long-poll worker. | | `VALKEY_ADDR` | `` or `` | Comma-separated for cluster / sentinel | | `VALKEY_TLS` | `true` \| `false` | Set `true` for managed Valkey with encryption in transit | | `VALKEY_USERNAME` | `` or empty | Managed Valkey with ACL (e.g. MemoryDB, Upstash) | @@ -170,6 +176,7 @@ WantedBy=multi-user.target | Symptom | Cause | |---|---| +| Spans not linked into one trace (orphan single-span traces) | See [span-linking troubleshooting in the product doc](https://last9.io/docs/integrations/golang-gin) — covers ctx propagation, outbound HTTP, SQS poller. | | DynamoDB span missing `aws.dynamodb.table_names` | `WithAttributeSetter(DynamoDBAttributeSetter)` not passed to `AppendMiddlewares` | | Valkey span missing or orphaned | Handler didn't pass `c.Request.Context()` into `valkeyClient.Do(...)` | | No traces in Last9 | `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` not set before `agent.Start()` | diff --git a/go/gin-dynamodb-valkey/go.mod b/go/gin-dynamodb-valkey/go.mod index 1c73f29..b64af37 100644 --- a/go/gin-dynamodb-valkey/go.mod +++ b/go/gin-dynamodb-valkey/go.mod @@ -8,11 +8,14 @@ require ( github.com/aws/aws-sdk-go-v2 v1.32.6 github.com/aws/aws-sdk-go-v2/config v1.28.6 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.38.0 + github.com/aws/aws-sdk-go-v2/service/sqs v1.37.0 github.com/gin-gonic/gin v1.10.0 github.com/last9/go-agent v0.1.0 github.com/valkey-io/valkey-go v1.0.55 github.com/valkey-io/valkey-go/valkeyotel v1.0.55 go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.57.0 + go.opentelemetry.io/otel v1.34.0 + go.opentelemetry.io/otel/trace v1.34.0 ) require ( @@ -24,7 +27,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sqs v1.37.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect @@ -34,6 +36,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -56,15 +59,15 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.52.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.50.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.27.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.33.0 // indirect diff --git a/go/gin-dynamodb-valkey/go.mod.new b/go/gin-dynamodb-valkey/go.mod.new deleted file mode 100644 index 68d400d..0000000 --- a/go/gin-dynamodb-valkey/go.mod.new +++ /dev/null @@ -1,15 +0,0 @@ -module gin-dynamodb-valkey - -go 1.22 - -require ( - github.com/aws/aws-sdk-go-v2 v1.32.6 - github.com/aws/aws-sdk-go-v2/config v1.28.6 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.38.0 - github.com/gin-gonic/gin v1.10.0 - github.com/last9/go-agent v0.1.0 - github.com/valkey-io/valkey-go v1.0.55 - github.com/valkey-io/valkey-go/valkeyotel v1.0.55 - go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.57.0 -) - diff --git a/go/gin-dynamodb-valkey/go.sum b/go/gin-dynamodb-valkey/go.sum index 141fc35..585186c 100644 --- a/go/gin-dynamodb-valkey/go.sum +++ b/go/gin-dynamodb-valkey/go.sum @@ -43,6 +43,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -131,6 +133,10 @@ go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.57.0/go.mod h1:aqXlYGrumc8b/n4z9eDHHoiLN4fq2DAO//wMnqdxPhg= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0 h1:vkioc4XBfqnZZ7u40wK3Kgbjj9JYkvW6FY1ghmM/Shk= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0/go.mod h1:vsyxiwPzPlijgouF1SRZRGqbuHod8fV6+MRCH7ltxDE= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.52.0 h1:Ud1trPqDHGSxyMiJ9a2XAdtTCXmRy0Yf7MjhW4dXogI= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.52.0/go.mod h1:l/UzmhdRx9YP37NI/nSr7l1bgG0dZnGfZf6C7TiV4jI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= go.opentelemetry.io/contrib/instrumentation/runtime v0.50.0 h1:6dck47miguAOny5MeqX1G8idd+HpzDFt86U33d7aW2I= go.opentelemetry.io/contrib/instrumentation/runtime v0.50.0/go.mod h1:rdPhRwNd2sHiRmwJAGs8xcwitqmP/j8pvl9X5jloYjU= go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0= diff --git a/go/gin-dynamodb-valkey/main.go b/go/gin-dynamodb-valkey/main.go index c65d97f..272dfa2 100644 --- a/go/gin-dynamodb-valkey/main.go +++ b/go/gin-dynamodb-valkey/main.go @@ -3,38 +3,168 @@ package main import ( "context" "crypto/tls" + "errors" "log" "net/http" "os" + "os/signal" "strings" + "syscall" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" dbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/gin-gonic/gin" "github.com/last9/go-agent" ginagent "github.com/last9/go-agent/instrumentation/gin" + httpagent "github.com/last9/go-agent/integrations/http" "github.com/valkey-io/valkey-go" "github.com/valkey-io/valkey-go/valkeyotel" otelaws "go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) +// tracer is package-global. The context.Context is NEVER cached on a struct — +// it is passed as the first argument to every method. Caching ctx at boot +// time is the most common cause of orphan span trees in Last9. +var tracer = otel.Tracer("gin-dynamodb-valkey") + +// Service holds long-lived dependencies (clients, table names). It does NOT +// hold a context.Context. Every method takes ctx as its first parameter so +// the request-scoped span context flows through unchanged. +type Service struct { + dyn *dynamodb.Client + valkey valkey.Client + http *http.Client + table string +} + +// GetUser fetches an item from DynamoDB. The custom internal span nests under +// the gin SERVER span because we receive ctx from the handler and pass it +// to dynClient.GetItem — otelaws picks up the parent automatically. +func (s *Service) GetUser(ctx context.Context, id string) (map[string]dbtypes.AttributeValue, error) { + ctx, span := tracer.Start(ctx, "Service.GetUser") + defer span.End() + span.SetAttributes(attribute.String("user.id", id)) + + out, err := s.dyn.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(s.table), + Key: map[string]dbtypes.AttributeValue{ + "user_id": &dbtypes.AttributeValueMemberS{Value: id}, + }, + }) + if err != nil { + span.RecordError(err) + return nil, err + } + return out.Item, nil +} + +// CacheGet reads a key from Valkey. Same ctx propagation rule. +func (s *Service) CacheGet(ctx context.Context, key string) (string, error) { + ctx, span := tracer.Start(ctx, "Service.CacheGet") + defer span.End() + return s.valkey.Do(ctx, s.valkey.B().Get().Key(key).Build()).ToString() +} + +// CacheSet writes a key to Valkey. +func (s *Service) CacheSet(ctx context.Context, key, value string) error { + ctx, span := tracer.Start(ctx, "Service.CacheSet") + defer span.End() + return s.valkey.Do(ctx, s.valkey.B().Set().Key(key).Value(value).Build()).Error() +} + +// CallExternal demonstrates outbound HTTP with trace-context propagation. +// httpagent.NewClient does two things: (1) emits a CLIENT span nested under +// ctx, (2) injects the W3C traceparent header so the receiving service joins +// this trace. A bare http.Client emits orphan spans AND breaks cross-service +// correlation. +func (s *Service) CallExternal(ctx context.Context, url string) (int, error) { + ctx, span := tracer.Start(ctx, "Service.CallExternal") + defer span.End() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + span.RecordError(err) + return 0, err + } + resp, err := s.http.Do(req) + if err != nil { + span.RecordError(err) + return 0, err + } + defer resp.Body.Close() + span.SetAttributes(attribute.Int("http.response.status_code", resp.StatusCode)) + return resp.StatusCode, nil +} + +// SQSPoller runs a long-poll receive loop. Background pollers have no inbound +// request, so each iteration explicitly starts a NEW ROOT span — the receive +// + per-message processing form one logical trace per batch. +type SQSPoller struct { + client *sqs.Client + queueURL string + handler func(ctx context.Context, body string) error +} + +// Run blocks until ctx is cancelled. The ctx passed in MUST NOT have a timeout +// shorter than WaitTimeSeconds (20s here) — otherwise every ReceiveMessage is +// cancelled mid-flight and recorded as STATUS_CODE_ERROR. +func (p *SQSPoller) Run(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + p.poll(ctx) + } +} + +func (p *SQSPoller) poll(parent context.Context) { + ctx, span := tracer.Start(parent, "sqs.poll", trace.WithNewRoot()) + defer span.End() + + out, err := p.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ + QueueUrl: aws.String(p.queueURL), + MaxNumberOfMessages: 10, + WaitTimeSeconds: 20, + }) + if err != nil { + span.RecordError(err) + return + } + span.SetAttributes(attribute.Int("messaging.batch.message_count", len(out.Messages))) + + for _, msg := range out.Messages { + if err := p.handler(ctx, aws.ToString(msg.Body)); err != nil { + span.RecordError(err) + continue + } + _, _ = p.client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ + QueueUrl: aws.String(p.queueURL), + ReceiptHandle: msg.ReceiptHandle, + }) + } +} + func newAWSConfig(ctx context.Context) aws.Config { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { log.Fatalf("aws config: %v", err) } - // Route AWS SDK v2 through OTel middleware. DynamoDBAttributeSetter - // enriches spans with table name and operation-specific attributes - // (e.g. aws.dynamodb.table_names) on top of the default RPC attributes. + // Register otelaws BEFORE building any service client. Middleware on the + // config flows into every client built from it. DynamoDBAttributeSetter + // adds aws.dynamodb.table_names + operation-specific attributes. otelaws.AppendMiddlewares( &cfg.APIOptions, otelaws.WithAttributeSetter(otelaws.DynamoDBAttributeSetter), ) - // AWS_ENDPOINT_URL supports local testing with amazon/dynamodb-local. if endpoint := os.Getenv("AWS_ENDPOINT_URL"); endpoint != "" { cfg.BaseEndpoint = aws.String(endpoint) } @@ -46,12 +176,6 @@ func newValkeyClient() valkey.Client { if addr == "" { addr = "localhost:6379" } - - // Options cover every managed + self-hosted deployment we have seen: - // - docker-compose / bare metal: defaults (plaintext, no auth) - // - AWS ElastiCache / MemoryDB: VALKEY_TLS=true, optional ACL user - // - Upstash / Aiven / Redis Cloud: VALKEY_TLS=true + VALKEY_PASSWORD - // Multiple nodes (cluster / sentinel): comma-separated VALKEY_ADDR. opt := valkey.ClientOption{ InitAddress: strings.Split(addr, ","), Username: os.Getenv("VALKEY_USERNAME"), @@ -60,9 +184,6 @@ func newValkeyClient() valkey.Client { if os.Getenv("VALKEY_TLS") == "true" { opt.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} } - - // valkeyotel.NewClient constructs an instrumented valkey client in a - // single call. It returns (valkey.Client, error) — no separate wrap step. client, err := valkeyotel.NewClient(opt) if err != nil { log.Fatalf("valkey: %v", err) @@ -71,7 +192,8 @@ func newValkeyClient() valkey.Client { } func main() { - ctx := context.Background() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() if err := agent.Start(); err != nil { log.Fatalf("go-agent: %v", err) @@ -79,44 +201,50 @@ func main() { defer agent.Shutdown() awsCfg := newAWSConfig(ctx) - dynClient := dynamodb.NewFromConfig(awsCfg) - - valkeyClient := newValkeyClient() - defer valkeyClient.Close() + svc := &Service{ + dyn: dynamodb.NewFromConfig(awsCfg), + valkey: newValkeyClient(), + // httpagent.NewClient wraps the transport with trace-context injection + // + automatic CLIENT span emission for every outbound request. + http: httpagent.NewClient(&http.Client{Timeout: 10 * time.Second}), + table: getenv("DYNAMODB_TABLE", "users"), + } + defer svc.valkey.Close() - table := os.Getenv("DYNAMODB_TABLE") - if table == "" { - table = "users" + // Optional SQS poller — runs only when SQS_QUEUE_URL is set. + if queueURL := os.Getenv("SQS_QUEUE_URL"); queueURL != "" { + poller := &SQSPoller{ + client: sqs.NewFromConfig(awsCfg), + queueURL: queueURL, + handler: func(ctx context.Context, body string) error { + _, span := tracer.Start(ctx, "process.message") + defer span.End() + span.SetAttributes(attribute.Int("message.length", len(body))) + return nil + }, + } + go poller.Run(ctx) } r := ginagent.Default() - // GET /users/:id — DynamoDB GetItem. - // c.Request.Context() propagates the HTTP server span so the DynamoDB - // span appears as its child in Last9 Trace Explorer. + // Every handler converts *gin.Context to c.Request.Context() once, then + // passes that ctx down through the service layer. Service methods take + // ctx as first arg — never read from a struct field. r.GET("/users/:id", func(c *gin.Context) { - out, err := dynClient.GetItem(c.Request.Context(), &dynamodb.GetItemInput{ - TableName: aws.String(table), - Key: map[string]dbtypes.AttributeValue{ - "user_id": &dbtypes.AttributeValueMemberS{Value: c.Param("id")}, - }, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if out.Item == nil { + item, err := svc.GetUser(c.Request.Context(), c.Param("id")) + switch { + case errors.Is(err, nil) && item == nil: c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return + case err != nil: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusOK, gin.H{"item": item}) } - c.JSON(http.StatusOK, gin.H{"item": out.Item}) }) - // GET /cache/:key — Valkey GET. r.GET("/cache/:key", func(c *gin.Context) { - val, err := valkeyClient.Do(c.Request.Context(), - valkeyClient.B().Get().Key(c.Param("key")).Build(), - ).ToString() + val, err := svc.CacheGet(c.Request.Context(), c.Param("key")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -124,31 +252,52 @@ func main() { c.JSON(http.StatusOK, gin.H{"key": c.Param("key"), "value": val}) }) - // POST /cache/:key — Valkey SET, used to seed keys during local testing. r.POST("/cache/:key", func(c *gin.Context) { value := c.Query("value") if value == "" { value = "hello" } - if err := valkeyClient.Do(c.Request.Context(), - valkeyClient.B().Set().Key(c.Param("key")).Value(value).Build(), - ).Error(); err != nil { + if err := svc.CacheSet(c.Request.Context(), c.Param("key"), value); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"key": c.Param("key"), "value": value}) }) + // Demonstrates outbound HTTP. The CLIENT span emitted by httpagent nests + // under the SERVER span; traceparent is injected so the receiver joins + // the same trace. + r.GET("/external", func(c *gin.Context) { + target := c.DefaultQuery("url", "https://httpbin.org/get") + status, err := svc.CallExternal(c.Request.Context(), target) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": status}) + }) + r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - log.Printf("listening on :%s", port) - if err := r.Run(":" + port); err != nil { - log.Fatal(err) + srv := &http.Server{Addr: ":" + getenv("PORT", "8080"), Handler: r} + go func() { + log.Printf("listening on %s", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + }() + + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdown) +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v } + return fallback } From ae7e76991fb42c95ec8e7e1b6b53b99d1e1511ef Mon Sep 17 00:00:00 2001 From: karthikeyangs9 Date: Mon, 4 May 2026 10:44:27 +0530 Subject: [PATCH 2/2] fix(gin-dynamodb-valkey): hardcode /external target to address SSRF (CodeQL #58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the /external handler for SSRF — the request URL came from the `?url=` query parameter, which is user-controlled. The endpoint exists purely to demonstrate httpagent.NewClient + ctx propagation, so the URL never needed to be configurable. Hardcoding to httpbin.org/get removes the attack surface without changing what the example teaches. --- go/gin-dynamodb-valkey/README.md | 2 +- go/gin-dynamodb-valkey/main.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go/gin-dynamodb-valkey/README.md b/go/gin-dynamodb-valkey/README.md index 6081351..475d972 100644 --- a/go/gin-dynamodb-valkey/README.md +++ b/go/gin-dynamodb-valkey/README.md @@ -49,7 +49,7 @@ Go/Gin service instrumented with the Last9 Go Agent — emits distributed traces curl -XPOST "http://localhost:8080/cache/hello?value=world" curl http://localhost:8080/cache/hello curl http://localhost:8080/users/u1 - curl "http://localhost:8080/external?url=https://httpbin.org/get" + curl http://localhost:8080/external ``` ## Context propagation diff --git a/go/gin-dynamodb-valkey/main.go b/go/gin-dynamodb-valkey/main.go index 272dfa2..343dba1 100644 --- a/go/gin-dynamodb-valkey/main.go +++ b/go/gin-dynamodb-valkey/main.go @@ -266,10 +266,9 @@ func main() { // Demonstrates outbound HTTP. The CLIENT span emitted by httpagent nests // under the SERVER span; traceparent is injected so the receiver joins - // the same trace. + // the same trace. Target is fixed to avoid SSRF in this demo. r.GET("/external", func(c *gin.Context) { - target := c.DefaultQuery("url", "https://httpbin.org/get") - status, err := svc.CallExternal(c.Request.Context(), target) + status, err := svc.CallExternal(c.Request.Context(), "https://httpbin.org/get") if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return