From 9c7d38697ec5196326fb87d9cdadec5bc9b564f4 Mon Sep 17 00:00:00 2001 From: Dustin Deus Date: Mon, 22 Jan 2024 18:08:56 +0100 Subject: [PATCH] feat(router): aws lambda support (#446) Co-authored-by: Suvij Surya --- .github/labeler.yml | 2 + .github/workflows/aws-lambda-router-ci.yaml | 63 ++++ .../workflows/aws-router-binary-release.yaml | 57 ++++ aws-lambda-router/.gitignore | 3 + aws-lambda-router/Makefile | 30 ++ aws-lambda-router/README.md | 86 +++++ aws-lambda-router/cmd/main.go | 99 ++++++ aws-lambda-router/cover.png | Bin 0 -> 65719 bytes aws-lambda-router/go.mod | 108 +++++++ aws-lambda-router/go.sum | 136 ++++++++ aws-lambda-router/internal/router.go | 148 +++++++++ aws-lambda-router/internal/router_test.go | 38 +++ aws-lambda-router/internal/version.go | 4 + aws-lambda-router/package.json | 19 ++ aws-lambda-router/router.json | 1 + aws-lambda-router/samconfig.toml | 31 ++ aws-lambda-router/template.yaml | 56 ++++ go.work | 1 + go.work.sum | 5 + graphqlmetrics/cmd/main.go | 16 +- graphqlmetrics/core/metrics_service.go | 71 +++-- graphqlmetrics/core/metrics_service_test.go | 4 + graphqlmetrics/go.mod | 1 + graphqlmetrics/go.sum | 2 + lerna.json | 3 +- router-tests/authentication_test.go | 2 +- router-tests/events_test.go | 2 +- router-tests/headers_test.go | 2 +- router-tests/integration_test.go | 2 +- router-tests/modules/module_test.go | 2 +- router-tests/persisted_operations_test.go | 2 +- router-tests/singleflight_test.go | 2 +- router-tests/testenv/testenv.go | 6 +- router-tests/websocket_test.go | 2 +- router/cmd/instance.go | 10 +- router/cmd/main.go | 4 +- router/core/access_controller.go | 3 +- router/core/context.go | 5 +- router/core/factoryresolver.go | 2 +- router/core/graphql_handler.go | 4 +- router/core/graphql_prehandler.go | 107 +++++-- router/core/header_rule_engine.go | 2 +- router/core/header_rule_engine_test.go | 2 +- router/core/modules.go | 6 +- router/core/operation_metrics.go | 130 +------- router/core/router.go | 293 +++++++++++------- router/core/router_metrics.go | 63 ++-- router/core/transport.go | 6 +- router/core/websocket.go | 10 +- router/internal/graphqlmetrics/batch_queue.go | 41 ++- .../graphqlmetrics/batch_queue_test.go | 8 +- router/internal/graphqlmetrics/exporter.go | 114 ++++++- .../internal/graphqlmetrics/exporter_test.go | 142 ++++++++- .../internal/graphqlmetrics/noop_exporter.go | 24 ++ .../recovery => recoveryhandler}/recovery.go | 2 +- .../recovery_test.go | 2 +- .../requestlogger/requestlogger.go | 7 + .../requestlogger/requestlogger_test.go | 2 +- .../authentication/authentication.go | 0 router/{ => pkg}/authentication/context.go | 0 router/{ => pkg}/authentication/doc.go | 0 router/{ => pkg}/authentication/http.go | 0 router/{ => pkg}/authentication/jwks.go | 0 router/{ => pkg}/config/config.go | 9 +- router/{ => pkg}/config/loadvariable.go | 0 router/{ => pkg}/config/marshaler.go | 0 .../{internal/handler => pkg}/cors/config.go | 0 router/{internal/handler => pkg}/cors/cors.go | 0 .../handler => pkg}/cors/cors_test.go | 0 .../{internal/handler => pkg}/cors/utils.go | 0 router/{ => pkg}/health/health.go | 0 router/{ => pkg}/health/health_test.go | 0 router/{internal => pkg}/logging/logging.go | 0 router/{internal => pkg}/metric/config.go | 4 +- router/{internal => pkg}/metric/meter.go | 2 +- router/{internal => pkg}/metric/metrics.go | 5 + .../{internal => pkg}/metric/noop_metrics.go | 4 + router/{internal => pkg}/metric/prometheus.go | 2 +- router/{internal => pkg}/otel/attributes.go | 0 .../otel/otelconfig/otelconfig.go | 6 +- router/{internal => pkg}/trace/config.go | 12 +- router/{internal => pkg}/trace/meter.go | 15 +- router/{internal => pkg}/trace/meter_test.go | 0 router/{internal => pkg}/trace/middleware.go | 0 .../trace/middleware_test.go | 5 +- router/{internal => pkg}/trace/propagation.go | 0 router/{internal => pkg}/trace/tracer.go | 0 router/{internal => pkg}/trace/tracer_test.go | 0 .../trace/tracetest/tracetest.go | 0 router/{internal => pkg}/trace/transport.go | 0 .../{internal => pkg}/trace/transport_test.go | 5 +- router/{internal => pkg}/trace/utils.go | 0 router/{internal => pkg}/trace/utils_test.go | 0 router/{internal => pkg}/trace/vars.go | 0 94 files changed, 1643 insertions(+), 421 deletions(-) create mode 100644 .github/workflows/aws-lambda-router-ci.yaml create mode 100644 .github/workflows/aws-router-binary-release.yaml create mode 100644 aws-lambda-router/.gitignore create mode 100644 aws-lambda-router/Makefile create mode 100644 aws-lambda-router/README.md create mode 100644 aws-lambda-router/cmd/main.go create mode 100644 aws-lambda-router/cover.png create mode 100644 aws-lambda-router/go.mod create mode 100644 aws-lambda-router/go.sum create mode 100644 aws-lambda-router/internal/router.go create mode 100644 aws-lambda-router/internal/router_test.go create mode 100644 aws-lambda-router/internal/version.go create mode 100644 aws-lambda-router/package.json create mode 100644 aws-lambda-router/router.json create mode 100644 aws-lambda-router/samconfig.toml create mode 100644 aws-lambda-router/template.yaml create mode 100644 router/internal/graphqlmetrics/noop_exporter.go rename router/internal/{handler/recovery => recoveryhandler}/recovery.go (99%) rename router/internal/{handler/recovery => recoveryhandler}/recovery_test.go (96%) rename router/internal/{handler => }/requestlogger/requestlogger.go (95%) rename router/internal/{handler => }/requestlogger/requestlogger_test.go (95%) rename router/{ => pkg}/authentication/authentication.go (100%) rename router/{ => pkg}/authentication/context.go (100%) rename router/{ => pkg}/authentication/doc.go (100%) rename router/{ => pkg}/authentication/http.go (100%) rename router/{ => pkg}/authentication/jwks.go (100%) rename router/{ => pkg}/config/config.go (99%) rename router/{ => pkg}/config/loadvariable.go (100%) rename router/{ => pkg}/config/marshaler.go (100%) rename router/{internal/handler => pkg}/cors/config.go (100%) rename router/{internal/handler => pkg}/cors/cors.go (100%) rename router/{internal/handler => pkg}/cors/cors_test.go (100%) rename router/{internal/handler => pkg}/cors/utils.go (100%) rename router/{ => pkg}/health/health.go (100%) rename router/{ => pkg}/health/health_test.go (100%) rename router/{internal => pkg}/logging/logging.go (100%) rename router/{internal => pkg}/metric/config.go (95%) rename router/{internal => pkg}/metric/meter.go (99%) rename router/{internal => pkg}/metric/metrics.go (98%) rename router/{internal => pkg}/metric/noop_metrics.go (91%) rename router/{internal => pkg}/metric/prometheus.go (96%) rename router/{internal => pkg}/otel/attributes.go (100%) rename router/{internal => pkg}/otel/otelconfig/otelconfig.go (77%) rename router/{internal => pkg}/trace/config.go (87%) rename router/{internal => pkg}/trace/meter.go (95%) rename router/{internal => pkg}/trace/meter_test.go (100%) rename router/{internal => pkg}/trace/middleware.go (100%) rename router/{internal => pkg}/trace/middleware_test.go (96%) rename router/{internal => pkg}/trace/propagation.go (100%) rename router/{internal => pkg}/trace/tracer.go (100%) rename router/{internal => pkg}/trace/tracer_test.go (100%) rename router/{internal => pkg}/trace/tracetest/tracetest.go (100%) rename router/{internal => pkg}/trace/transport.go (100%) rename router/{internal => pkg}/trace/transport_test.go (96%) rename router/{internal => pkg}/trace/utils.go (100%) rename router/{internal => pkg}/trace/utils_test.go (100%) rename router/{internal => pkg}/trace/vars.go (100%) diff --git a/.github/labeler.yml b/.github/labeler.yml index 87f9398d2f..3f3b84bb3e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -17,3 +17,5 @@ controlplane: - "controlplane/**/*" graphqlmetrics: - "graphqlmetrics/**/*" +aws-lambda-router: + - "aws-lambda-router/**/*" diff --git a/.github/workflows/aws-lambda-router-ci.yaml b/.github/workflows/aws-lambda-router-ci.yaml new file mode 100644 index 0000000000..a7234b9a32 --- /dev/null +++ b/.github/workflows/aws-lambda-router-ci.yaml @@ -0,0 +1,63 @@ +name: AWS Lambda Router CI +on: + pull_request: + paths: + - "aws-lambda-router/**/*" + - "router-tests/**/*" + - ".github/workflows/aws-lambda-router-ci.yaml" + +concurrency: + group: ${{github.workflow}}-${{github.head_ref}} + cancel-in-progress: true + +env: + CI: true + +jobs: + build_test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + # The go install / version instructions are inside the Makefile, so we need to cache the Makefile. + key: ${{ runner.os }}-go-${{ hashFiles('aws-lambda-router/go.sum') }}-makefile-${{ hashFiles('Makefile') }} + restore-keys: | + ${{ runner.os }}-go- + + - uses: ./.github/actions/go + with: + cache-dependency-path: router/go.sum + + - name: Install tools + run: make setup-build-tools + + - name: Generate code + run: make generate-go + + - name: Check if git is not dirty after generating files + run: git diff --no-ext-diff --exit-code + + - name: Install dependencies + working-directory: ./aws-lambda-router + run: go mod download + + - name: Run linters on router + uses: ./.github/actions/go-linter + with: + working-directory: ./aws-lambda-router + + - name: Test + working-directory: ./aws-lambda-router + run: make test + + - name: Build + working-directory: ./aws-lambda-router + run: make build diff --git a/.github/workflows/aws-router-binary-release.yaml b/.github/workflows/aws-router-binary-release.yaml new file mode 100644 index 0000000000..8d579d6f76 --- /dev/null +++ b/.github/workflows/aws-router-binary-release.yaml @@ -0,0 +1,57 @@ +name: Build and Release AWS Router Binaries +on: + release: + types: [published] + #workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + releases-matrix: + if: startsWith(github.event.release.tag_name, 'aws-lambda-router@') + name: Build and Release AWS Router Binaries + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + matrix: + # build and publish in parallel: linux/386, linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 + goos: [linux, darwin] + goarch: ["386", amd64, arm64] + exclude: + - goarch: "386" + goos: darwin + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - uses: ./.github/actions/go + with: + cache-dependency-path: router/go.sum + + - uses: winterjung/split@v2 + id: split + with: + separator: "@" + msg: "${{ github.event.release.tag_name }}" + + - uses: wangyoucao577/go-release-action@v1 + name: Build and attach binaries to GitHub Release + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + # Where to run `go build .` + project_path: "aws-lambda-router/cmd" + # Convention from AWS Lambda + binary_name: "bootstrap" + pre_command: export CGO_ENABLED=0 + build_flags: -trimpath + # -w = omits the DWARF symbol table, effectively removing debugging information. Reduces binary size by ~30%. + ldflags: -w -extldflags -static -X module github.com/wundergraph/cosmo/aws-lambda-router/internal.Version=${{ steps.split.outputs._1 }} + overwrite: true + extra_files: LICENSE + #release_tag: router@0.14.0 \ No newline at end of file diff --git a/aws-lambda-router/.gitignore b/aws-lambda-router/.gitignore new file mode 100644 index 0000000000..220a5a9cb5 --- /dev/null +++ b/aws-lambda-router/.gitignore @@ -0,0 +1,3 @@ +.aws-sam +bootstrap +lambda.zip \ No newline at end of file diff --git a/aws-lambda-router/Makefile b/aws-lambda-router/Makefile new file mode 100644 index 0000000000..7d8898ebbb --- /dev/null +++ b/aws-lambda-router/Makefile @@ -0,0 +1,30 @@ +.PHONY: build + +VERSION?=dev +build: + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-extldflags -static -X github.com/wundergraph/cosmo/aws-lambda-router/internal.Version=$(VERSION)" -a -o bootstrap cmd/main.go + +build-sam: + rm -rf .aws-sam && sam build --parallel && cp router.json .aws-sam/build/Api/router.json + +dev: build-sam + sam local start-api -p 3003 --shutdown + +deploy: build-sam + sam deploy + +lint: + cd adapter && go vet ./... + cd adapter && staticcheck ./... + +test: + go test -v ./... + +fetch-router-config: + wgc router fetch production -o router.json + +sync: + sam sync --watch + +create-lambda-zip: build fetch-router-config + zip -r lambda.zip bootstrap router.json \ No newline at end of file diff --git a/aws-lambda-router/README.md b/aws-lambda-router/README.md new file mode 100644 index 0000000000..2d1c8297c0 --- /dev/null +++ b/aws-lambda-router/README.md @@ -0,0 +1,86 @@ +# aws-lambda-router + +

+ +

+ +This is the [AWS Lambda](https://aws.amazon.com/lambda/) version of the WunderGraph Cosmo [Router](https://wundergraph.com/cosmo/features/router). Please [contact](https://wundergraph.com/contact/sales) us if you have any questions or production use case. +Why AWS lambda? Because it's cheap and scales automatically. You only pay for what you use. No need to manage servers or containers. It also integrates well with the rest of the AWS ecosystem. + +Status: **Beta** + +Demo: [https://zqadzbqwsi.execute-api.us-west-1.amazonaws.com/Prod/](https://zqadzbqwsi.execute-api.us-west-1.amazonaws.com/Prod/) (Playground) + +## Features + +- [X] GraphQL Queries +- [X] GraphQL Mutations +- [X] Telemetry Flushing after each request +- [X] Schema Usage Tracking after each request +- [ ] Subscription: Not implemented. Please [talk to us](https://wundergraph.com/contact/sales) if you need this. + +## Requirements + +* AWS CLI already configured with Administrator permission +* [Docker installed](https://www.docker.com/community-edition) +* [Golang](https://golang.org) +* SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) + +# Setup process + +## Cosmo Cloud + +First signup For Cosmo Cloud and follow the [onboarding](https://cosmo-docs.wundergraph.com/tutorial/cosmo-cloud-onboarding) process. + +Run `make fetch-router-config` to fetch the latest router configuration from Cosmo Cloud. We assume that you have named your graph `production`. +The file is stored in `router.json` and copied to the Lambda build directory on each build. + +## Local development + +Build the router and start the API Gateway locally: + +```bash +make dev +``` + +Open [http://127.0.0.1:3003/](http://127.0.0.1:3003/) in your browser and you should see the GraphQL Playground. + +### Deploy on code change + +This will upload the code to AWS without performing a CloudFormation deployment. This is useful for development. + +```bash +make sync +``` + +## Deploying application + +Ensure that the following environment variables are set in [template.yaml](template.yaml): + +- `STAGE` - The name of the stage, which API Gateway uses as the first path segment in the invoke Uniform Resource Identifier (URI) e.g. `Prod` for `/Prod`. +- `GRAPH_API_TOKEN` - The API token for your graph. You can find this in the Cosmo Cloud dashboard. + +*For production use cases, we recommend to use [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) to store the `GRAPH_API_TOKEN`.* + +```bash +make deploy +``` + +The command will package and deploy your application with the SAM CLI to AWS. +You can find your API Gateway Endpoint URL in the output values displayed after deployment. + +# User Guide + +You don't have to build the router yourself. You can download the latest release and follow the instructions below. + +1. Download the Lambda Router binary from the official [Router Releases](https://github.com/wundergraph/cosmo/releases?q=aws-lambda-router&expanded=true) page. +2. Create a .zip archive with the binary and the `router.json` file. You can download the latest `router.json` with [`wgc federated-graph fetch`](https://cosmo-docs.wundergraph.com/cli/federated-graph/fetch). + +The .zip archive should look like this: +```bash +. +└── myFunction.zip/ + ├── bootstrap # Extracted from the Router release archive + └── router.json # Downloaded with `wgc federated-graph fetch` +``` +3. Deploy the .zip archive to AWS Lambda. You can use SAM CLI or the AWS console. Alternatively, you can use your IaC tool of choice. diff --git a/aws-lambda-router/cmd/main.go b/aws-lambda-router/cmd/main.go new file mode 100644 index 0000000000..87799b1982 --- /dev/null +++ b/aws-lambda-router/cmd/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "errors" + "fmt" + "github.com/akrylysov/algnhsa" + "github.com/aws/aws-lambda-go/lambda" + "github.com/wundergraph/cosmo/aws-lambda-router/internal" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/logging" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "net/http" + "os" + "time" +) + +const ( + telemetryServiceName = "aws-lambda-router" + routerConfigPath = "router.json" +) + +var ( + defaultSampleRate = 0.2 // 20% of requests will be sampled + enableTelemetry = os.Getenv("DISABLE_TELEMETRY") != "true" + devMode = os.Getenv("DEV_MODE") == "true" + stage = os.Getenv("STAGE") + graphApiToken = os.Getenv("GRAPH_API_TOKEN") + httpPort = os.Getenv("HTTP_PORT") +) + +func main() { + ctx := context.Background() + + logger := logging.New(false, false, zapcore.InfoLevel) + logger = logger.With( + zap.String("service_version", internal.Version), + ) + defer func() { + if err := logger.Sync(); err != nil { + fmt.Println("Could not sync logger", err) + } + }() + + r := internal.NewRouter( + internal.WithGraphApiToken(graphApiToken), + internal.WithLogger(logger), + internal.WithRouterConfigPath(routerConfigPath), + internal.WithTelemetryServiceName(telemetryServiceName), + internal.WithStage(stage), + internal.WithTraceSampleRate(defaultSampleRate), + internal.WithEnableTelemetry(enableTelemetry), + internal.WithHttpPort(httpPort), + internal.WithRouterOpts(core.WithDevelopmentMode(devMode)), + internal.WithRouterOpts( + core.WithEngineExecutionConfig(config.EngineExecutionConfiguration{ + EnableSingleFlight: true, + EnableRequestTracing: devMode, + EnableExecutionPlanCacheResponseHeader: devMode, + MaxConcurrentResolvers: 1024, + }), + ), + ) + + svr, err := r.NewServer(ctx) + if err != nil { + logger.Fatal("Could not create server", zap.Error(err)) + } + + // Set the server to ready + svr.HealthChecks().SetReady(true) + + // If HTTP_PORT is set, we assume we are running the router without lambda + if httpPort != "" { + if err := svr.HttpServer().ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatal("Could not start server", zap.Error(err)) + } + return + } + + lambdaHandler := algnhsa.New(svr.HttpServer().Handler, nil) + lambda.StartWithOptions(lambdaHandler, + lambda.WithContext(ctx), + // Registered an internal extensions which gives us 500ms to shutdown + // This mechanism does not replace telemetry flushing after a request + // https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html#runtimes-lifecycle-extensions-shutdown + lambda.WithEnableSIGTERM(func() { + logger.Debug("Server shutting down") + sCtx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + if err := r.Shutdown(sCtx); err != nil { + panic(err) + } + logger.Debug("Server shutdown") + }), + ) +} diff --git a/aws-lambda-router/cover.png b/aws-lambda-router/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..cbf8d65820e23991b60320d055e8d5d304678187 GIT binary patch literal 65719 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 literal 0 HcmV?d00001 diff --git a/aws-lambda-router/go.mod b/aws-lambda-router/go.mod new file mode 100644 index 0000000000..c53c6b6c29 --- /dev/null +++ b/aws-lambda-router/go.mod @@ -0,0 +1,108 @@ +module github.com/wundergraph/cosmo/aws-lambda-router + +require ( + github.com/akrylysov/algnhsa v1.1.0 + github.com/aws/aws-lambda-go v1.43.0 + github.com/stretchr/testify v1.8.4 + go.uber.org/zap v1.26.0 +) + +require ( + connectrpc.com/connect v1.11.1 // indirect + github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect + github.com/alitto/pond v1.8.3 // indirect + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/sonic v1.10.0-rc // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-chi/chi v1.5.4 // indirect + github.com/go-logr/logr v1.4.1 // 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.15.5 // indirect + github.com/gobwas/ws v1.3.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.11.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/golang/glog v1.1.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jensneuse/abstractlogger v0.0.4 // indirect + github.com/jensneuse/byte-template v0.0.0-20200214152254-4f3cf06e5c68 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattbaird/jsonpatch v0.0.0-20230413205102-771768614e91 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/r3labs/sse/v2 v2.8.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/sosodev/duration v1.1.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.2.0.20240110181439-71bf34cedd29 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect + go.opentelemetry.io/otel v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.44.0 // indirect + go.opentelemetry.io/otel/metric v1.21.0 // indirect + go.opentelemetry.io/otel/sdk v1.21.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.21.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.withmatt.com/connect-brotli v0.4.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sync v0.4.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + nhooyr.io/websocket v1.8.7 // indirect +) + +go 1.21 diff --git a/aws-lambda-router/go.sum b/aws-lambda-router/go.sum new file mode 100644 index 0000000000..2ca1346cc4 --- /dev/null +++ b/aws-lambda-router/go.sum @@ -0,0 +1,136 @@ +connectrpc.com/connect v1.11.1 h1:dqRwblixqkVh+OFBOOL1yIf1jS/yP0MSJLijRj29bFg= +github.com/99designs/gqlgen v0.17.39 h1:wPTAyc2fqVjAWT5DsJ21k/lLudgnXzURwbsjVNegFpU= +github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/akrylysov/algnhsa v1.1.0 h1:G0SoP16tMRyiism7VNc3JFA0wq/cVgEkp/ExMVnc6PQ= +github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/aws/aws-lambda-go v1.43.0 h1:Tdu7SnMB5bD+CbdnSq1Dg4sM68vEuGIDcQFZ+IjUfx0= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/bytedance/sonic v1.10.0-rc h1:3S5HeWxjX08CUqNrXtEittExpJsEKBNzrV5UnrzHxVQ= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= +github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a h1:8d1CEOF1xldesKds5tRG3tExBsMOgWYownMHNCsev54= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/jensneuse/abstractlogger v0.0.4 h1:sa4EH8fhWk3zlTDbSncaWKfwxYM8tYSlQ054ETLyyQY= +github.com/jensneuse/byte-template v0.0.0-20200214152254-4f3cf06e5c68 h1:E80wOd3IFQcoBxLkAUpUQ3BoGrZ4DxhQdP21+HH1s6A= +github.com/jensneuse/diffview v1.0.0 h1:4b6FQJ7y3295JUHU3tRko6euyEboL825ZsXeZZM47Z4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4= +github.com/mattbaird/jsonpatch v0.0.0-20230413205102-771768614e91 h1:JnZSkFP1/GLwKCEuuWVhsacvbDQIVa5BRwAwd+9k2Vw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 h1:uIkTLo0AGRc8l7h5l9r+GcYi9qfVPt6lD4/bhmzfiKo= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sosodev/duration v1.1.0 h1:kQcaiGbJaIsRqgQy7VGlZrVw1giWO+lDoX3MCPnpVO4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/vektah/gqlparser/v2 v2.5.10 h1:6zSM4azXC9u4Nxy5YmdmGu4uKamfwsdKTwp5zsEealU= +github.com/wundergraph/cosmo/router v0.0.0-20231210173116-4cf620b03fbb h1:80wThnmhc+wk63zdCsrp5pKzTrvl2LOk6rKV0K4dLcQ= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.2.0.20240110181439-71bf34cedd29 h1:ECO1hqa3nRZgf9eMKzG2vqmqGlnTw2dqtUClTbISUBo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/prometheus v0.44.0 h1:08qeJgaPC0YEBu2PQMbqU3rogTlyzpjhCI2b58Yn00w= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0= +go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.withmatt.com/connect-brotli v0.4.0 h1:7ObWkYmEbUXK3EKglD0Lgj0BBnnD3jNdAxeDRct3l8E= +golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= +google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= diff --git a/aws-lambda-router/internal/router.go b/aws-lambda-router/internal/router.go new file mode 100644 index 0000000000..a43fba5416 --- /dev/null +++ b/aws-lambda-router/internal/router.go @@ -0,0 +1,148 @@ +package internal + +import ( + "fmt" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/trace" + "go.uber.org/zap" +) + +type Option func(*RouterConfig) + +type RouterConfig struct { + RouterConfigPath string + TelemetryServiceName string + RouterOpts []core.Option + GraphApiToken string + HttpPort string + EnableTelemetry bool + Stage string + TraceSampleRate float64 + Logger *zap.Logger +} + +func NewRouter(opts ...Option) *core.Router { + + rc := &RouterConfig{} + + for _, opt := range opts { + opt(rc) + } + + if rc.Logger == nil { + rc.Logger = zap.NewNop() + } + + logger := rc.Logger + + routerConfig, err := core.SerializeConfigFromFile(rc.RouterConfigPath) + if err != nil { + logger.Fatal("Could not read router config", zap.Error(err), zap.String("path", rc.RouterConfigPath)) + } + + routerOpts := []core.Option{ + core.WithLogger(logger), + core.WithPlayground(true), + core.WithIntrospection(true), + core.WithStaticRouterConfig(routerConfig), + core.WithAwsLambdaRuntime(), + core.WithGraphApiToken(rc.GraphApiToken), + } + + if rc.HttpPort != "" { + routerOpts = append(routerOpts, core.WithListenerAddr(":"+rc.HttpPort)) + } + + if rc.EnableTelemetry { + routerOpts = append(routerOpts, + core.WithGraphQLMetrics(&core.GraphQLMetricsConfig{ + Enabled: true, + CollectorEndpoint: "https://cosmo-metrics.wundergraph.com", + }), + core.WithMetrics(&metric.Config{ + Name: rc.TelemetryServiceName, + Version: Version, + OpenTelemetry: metric.OpenTelemetry{ + Enabled: true, + }, + }), + core.WithTracing(&trace.Config{ + Enabled: true, + Name: rc.TelemetryServiceName, + Version: Version, + Sampler: rc.TraceSampleRate, + Propagators: []trace.Propagator{ + trace.PropagatorTraceContext, + }, + }), + ) + } + + if rc.Stage != "" { + routerOpts = append(routerOpts, + core.WithGraphQLWebURL(fmt.Sprintf("/%s%s", rc.Stage, "/graphql")), + ) + } + + r, err := core.NewRouter(append(rc.RouterOpts, routerOpts...)...) + if err != nil { + logger.Fatal("Could not create router", zap.Error(err)) + } + + return r +} + +func WithRouterConfigPath(path string) Option { + return func(r *RouterConfig) { + r.RouterConfigPath = path + } +} + +func WithTelemetryServiceName(name string) Option { + return func(r *RouterConfig) { + r.TelemetryServiceName = name + } +} + +func WithRouterOpts(opts ...core.Option) Option { + return func(r *RouterConfig) { + r.RouterOpts = append(r.RouterOpts, opts...) + } +} + +func WithGraphApiToken(token string) Option { + return func(r *RouterConfig) { + r.GraphApiToken = token + } +} + +func WithHttpPort(port string) Option { + return func(r *RouterConfig) { + r.HttpPort = port + } +} + +func WithEnableTelemetry(enable bool) Option { + return func(r *RouterConfig) { + r.EnableTelemetry = enable + } +} + +func WithStage(stage string) Option { + return func(r *RouterConfig) { + r.Stage = stage + } +} + +func WithTraceSampleRate(rate float64) Option { + return func(r *RouterConfig) { + r.TraceSampleRate = rate + } +} + +func WithLogger(logger *zap.Logger) Option { + return func(r *RouterConfig) { + r.Logger = logger + } +} diff --git a/aws-lambda-router/internal/router_test.go b/aws-lambda-router/internal/router_test.go new file mode 100644 index 0000000000..5fa05da980 --- /dev/null +++ b/aws-lambda-router/internal/router_test.go @@ -0,0 +1,38 @@ +package internal + +import ( + "context" + "encoding/json" + "github.com/akrylysov/algnhsa" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "testing" + + "github.com/aws/aws-lambda-go/events" +) + +func TestHandler(t *testing.T) { + logger, err := zap.NewProduction() + require.NoError(t, err) + + r := NewRouter( + WithLogger(logger), + WithRouterConfigPath("../router.json"), + ) + require.NoError(t, err) + + svr, err := r.NewServer(context.Background()) + require.NoError(t, err) + + handler := algnhsa.New(svr.HttpServer().Handler, &algnhsa.Options{ + RequestType: algnhsa.RequestTypeAPIGatewayV2, + }) + j, err := json.Marshal(events.APIGatewayV2HTTPRequest{ + Version: "2.0", + RawPath: "/health", + }) + require.NoError(t, err) + response, err := handler.Invoke(context.Background(), j) + require.NoError(t, err) + require.NotEmpty(t, response) +} diff --git a/aws-lambda-router/internal/version.go b/aws-lambda-router/internal/version.go new file mode 100644 index 0000000000..79f51cd393 --- /dev/null +++ b/aws-lambda-router/internal/version.go @@ -0,0 +1,4 @@ +package internal + +// Version set by the build system. +var Version = "dev" diff --git a/aws-lambda-router/package.json b/aws-lambda-router/package.json new file mode 100644 index 0000000000..ffe756244d --- /dev/null +++ b/aws-lambda-router/package.json @@ -0,0 +1,19 @@ +{ + "name": "aws-lambda-router", + "version": "0.0.1", + "private": true, + "description": "Placeholder package to simplify versioning and releasing with lerna.", + "keywords": [ + "wundergraph", + "router", + "cosmo", + "graphql", + "aws-lambda", + "serverless" + ], + "author": { + "name": "WunderGraph Maintainers", + "email": "info@wundergraph.com" + }, + "license": "Apache-2.0" +} diff --git a/aws-lambda-router/router.json b/aws-lambda-router/router.json new file mode 100644 index 0000000000..61bcb8fcf1 --- /dev/null +++ b/aws-lambda-router/router.json @@ -0,0 +1 @@ +{"engineConfig":{"defaultFlushInterval":"500","datasourceConfigurations":[{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["productTypes"]},{"typeName":"Employee","fieldNames":["id","products","notes"]},{"typeName":"Consultancy","fieldNames":["upc","name"]},{"typeName":"Cosmo","fieldNames":["upc","name","repositoryURL"]}],"childNodes":[{"typeName":"Documentation","fieldNames":["url","urls"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"https://product-api.fly.dev/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS"},"federation":{"enabled":true,"serviceSdl":"schema {\n query: Queries\n}\n\ntype Queries {\n productTypes: [Products!]!\n}\n\nenum ProductName {\n CONSULTANCY\n COSMO\n ENGINE\n FINANCE\n HUMAN_RESOURCES\n MARKETING\n SDK\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n products: [ProductName!]!\n notes: String! @override(from: \"employees\")\n}\n\nunion Products = Consultancy | Cosmo | Documentation\n\ntype Consultancy @key(fields: \"upc\") {\n upc: ID!\n name: ProductName!\n}\n\ntype Cosmo @key(fields: \"upc\") {\n upc: ID!\n name: ProductName!\n repositoryURL: String!\n}\n\ntype Documentation {\n url(product: ProductName!): String!\n urls(products: [ProductName!]!): [String!]!\n}"},"upstreamSchema":{"key":"28d075e54f235ecd9400d345641679ba46d44720"}},"requestTimeoutSeconds":"10","id":"a6357085-294c-443a-a658-d0a0e18b5ee3","keys":[{"typeName":"Employee","selectionSet":"id"},{"typeName":"Consultancy","selectionSet":"upc"},{"typeName":"Cosmo","selectionSet":"upc"}]},{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["findEmployees"]},{"typeName":"Employee","fieldNames":["details","id"]}],"childNodes":[{"typeName":"Animal","fieldNames":["class","gender"]},{"typeName":"Pet","fieldNames":["class","gender","name"]},{"typeName":"Alligator","fieldNames":["class","dangerous","gender","name"]},{"typeName":"Cat","fieldNames":["class","gender","name","type"]},{"typeName":"Dog","fieldNames":["breed","class","gender","name"]},{"typeName":"Mouse","fieldNames":["class","gender","name"]},{"typeName":"Pony","fieldNames":["class","gender","name"]},{"typeName":"Details","fieldNames":["forename","surname","hasChildren","maritalStatus","nationality","pets"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"https://family-api.fly.dev/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS"},"federation":{"enabled":true,"serviceSdl":"type Query {\n findEmployees(criteria: SearchInput): [Employee!]!\n}\n\nenum Class {\n FISH\n MAMMAL\n REPTILE\n}\n\nenum Gender {\n FEMALE\n MALE\n UNKNOWN\n}\n\ninterface Animal {\n class: Class!\n gender: Gender!\n}\n\ninterface Pet implements Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\nenum CatType {\n HOME\n STREET\n}\n\ntype Alligator implements Pet & Animal {\n class: Class!\n dangerous: String!\n gender: Gender!\n name: String!\n}\n\ntype Cat implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n type: CatType!\n}\n\nenum DogBreed {\n GOLDEN_RETRIEVER\n POODLE\n ROTTWEILER\n YORKSHIRE_TERRIER\n}\n\ntype Dog implements Pet & Animal {\n breed: DogBreed!\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Mouse implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Pony implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\nenum MaritalStatus {\n ENGAGED\n MARRIED\n}\n\nenum Nationality {\n AMERICAN\n DUTCH\n ENGLISH\n GERMAN\n INDIAN\n SPANISH\n UKRAINIAN\n}\n\ntype Details {\n forename: String! @shareable\n surname: String! @shareable\n hasChildren: Boolean!\n maritalStatus: MaritalStatus\n nationality: Nationality!\n pets: [Pet]\n}\n\ntype Employee @key(fields: \"id\") {\n details: Details @shareable\n id: Int!\n}\n\ninput SearchInput {\n hasPets: Boolean\n nationality: Nationality\n nested: NestedSearchInput\n}\n\ninput NestedSearchInput {\n maritalStatus: MaritalStatus\n hasChildren: Boolean\n}"},"upstreamSchema":{"key":"8db3c709c4ebcc8bfea331fbfdbefdb300b18d2d"}},"requestTimeoutSeconds":"10","id":"8fb1f8e6-04dc-4480-b6a6-5109b59f4b6d","keys":[{"typeName":"Employee","selectionSet":"id"}]},{"kind":"GRAPHQL","rootNodes":[{"typeName":"Employee","fieldNames":["id","hobbies"]},{"typeName":"SDK","fieldNames":["upc","clientLanguages"]}],"childNodes":[{"typeName":"Exercise","fieldNames":["category"]},{"typeName":"Experience","fieldNames":["yearsOfExperience"]},{"typeName":"Flying","fieldNames":["planeModels","yearsOfExperience"]},{"typeName":"Gaming","fieldNames":["genres","name","yearsOfExperience"]},{"typeName":"Other","fieldNames":["name"]},{"typeName":"Programming","fieldNames":["languages"]},{"typeName":"Travelling","fieldNames":["countriesLived"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"https://hobbies-api.fly.dev/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS"},"federation":{"enabled":true,"serviceSdl":"enum ExerciseType {\n CALISTHENICS\n HIKING\n SPORT\n STRENGTH_TRAINING\n}\n\ntype Exercise {\n category: ExerciseType!\n}\n\ninterface Experience {\n yearsOfExperience: Float!\n}\n\ntype Flying implements Experience {\n planeModels: [String!]!\n yearsOfExperience: Float!\n}\n\nenum GameGenre {\n ADVENTURE\n BOARD\n FPS\n CARD\n RPG\n ROGUELITE\n SIMULATION\n STRATEGY\n}\n\ntype Gaming implements Experience {\n genres: [GameGenre!]!\n name: String!\n yearsOfExperience: Float!\n}\n\ntype Other {\n name: String!\n}\n\nenum ProgrammingLanguage {\n CSHARP\n GO\n RUST\n TYPESCRIPT\n}\n\ntype Programming {\n languages: [ProgrammingLanguage!]!\n}\n\nenum Country {\n AMERICA\n ENGLAND\n GERMANY\n INDONESIA\n KOREA\n NETHERLANDS\n PORTUGAL\n SERBIA\n SPAIN\n TAIWAN\n THAILAND\n}\n\ntype Travelling {\n countriesLived: [Country!]!\n}\n\nunion Hobby = Exercise | Flying | Gaming | Programming | Travelling | Other\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n hobbies: [Hobby!]!\n}\n\ntype SDK @key(fields: \"upc\") {\n upc: ID!\n clientLanguages: [ProgrammingLanguage!]!\n}"},"upstreamSchema":{"key":"9424607e5cb88b2944cfa4b9793d2447652731e8"}},"requestTimeoutSeconds":"10","id":"dea1e48a-5b09-4d48-b10d-dcb5663cf582","keys":[{"typeName":"Employee","selectionSet":"id"},{"typeName":"SDK","selectionSet":"upc"}]},{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["employee","employees","products","teammates"]},{"typeName":"Mutation","fieldNames":["updateEmployeeTag"]},{"typeName":"Subscription","fieldNames":["currentTime"]},{"typeName":"Employee","fieldNames":["details","id","tag","role","updatedAt"]},{"typeName":"Consultancy","fieldNames":["upc","lead"]},{"typeName":"Cosmo","fieldNames":["upc","engineers","lead"]},{"typeName":"SDK","fieldNames":["upc","engineers","owner"]}],"childNodes":[{"typeName":"RoleType","fieldNames":["departments","title"]},{"typeName":"Identifiable","fieldNames":["id"]},{"typeName":"Engineer","fieldNames":["departments","engineerType","title"]},{"typeName":"Marketer","fieldNames":["departments","title"]},{"typeName":"Operator","fieldNames":["departments","operatorType","title"]},{"typeName":"Details","fieldNames":["forename","location","surname"]},{"typeName":"Time","fieldNames":["unixTime","timeStamp"]},{"typeName":"IProduct","fieldNames":["upc","engineers"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"https://employees-api.fly.dev/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS"},"federation":{"enabled":true,"serviceSdl":"type Query {\n employee(id: Int!): Employee\n employees: [Employee!]!\n products: [Products!]!\n teammates(team: Department!): [Employee!]!\n}\n\ntype Mutation {\n updateEmployeeTag(id: Int!, tag: String!): Employee\n}\n\ntype Subscription {\n \"\"\"\n `currentTime` will return a stream of `Time` objects.\n \"\"\"\n currentTime: Time!\n}\n\nenum Department {\n ENGINEERING\n MARKETING\n OPERATIONS\n}\n\ninterface RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\nenum EngineerType {\n BACKEND\n FRONTEND\n FULLSTACK\n}\n\ninterface Identifiable {\n id: Int!\n}\n\ntype Engineer implements RoleType {\n departments: [Department!]!\n engineerType: EngineerType!\n title: [String!]!\n}\n\ntype Marketer implements RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\nenum OperationType {\n FINANCE\n HUMAN_RESOURCES\n}\n\ntype Operator implements RoleType {\n departments: [Department!]!\n operatorType: [OperationType!]!\n title: [String!]!\n}\n\nenum Country {\n AMERICA\n ENGLAND\n GERMANY\n INDIA\n NETHERLANDS\n PORTUGAL\n SPAIN\n UKRAINE\n}\n\ntype Details @shareable {\n forename: String!\n location: Country!\n surname: String!\n}\n\ntype Employee implements Identifiable @key(fields: \"id\") {\n details: Details! @shareable\n id: Int!\n tag: String!\n role: RoleType!\n notes: String\n updatedAt: String!\n}\n\ntype Time {\n unixTime: Int!\n timeStamp: String!\n}\n\nunion Products = Consultancy | Cosmo | SDK\n\ninterface IProduct {\n upc: ID!\n engineers: [Employee!]!\n}\n\ntype Consultancy @key(fields: \"upc\") {\n upc: ID!\n lead: Employee!\n}\n\ntype Cosmo implements IProduct @key(fields: \"upc\") {\n upc: ID!\n engineers: [Employee!]!\n lead: Employee!\n}\n\ntype SDK implements IProduct @key(fields: \"upc\") {\n upc: ID!\n engineers: [Employee!]!\n owner: Employee!\n}\n"},"upstreamSchema":{"key":"3ff225598d21485cbe809f9a750b57bdfcaf5010"}},"requestTimeoutSeconds":"10","id":"a06cbefa-1f19-4e3f-9b9c-74bf3688d1d9","keys":[{"typeName":"Employee","selectionSet":"id"},{"typeName":"Consultancy","selectionSet":"upc"},{"typeName":"Cosmo","selectionSet":"upc"},{"typeName":"SDK","selectionSet":"upc"}]}],"fieldConfigurations":[{"typeName":"Query","fieldName":"findEmployees","argumentsConfiguration":[{"name":"criteria","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"employee","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"teammates","argumentsConfiguration":[{"name":"team","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Documentation","fieldName":"url","argumentsConfiguration":[{"name":"product","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Documentation","fieldName":"urls","argumentsConfiguration":[{"name":"products","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Mutation","fieldName":"updateEmployeeTag","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"},{"name":"tag","sourceType":"FIELD_ARGUMENT"}]}],"graphqlSchema":"directive @tag(name: String!) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ndirective @inaccessible on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\nunion Products = Consultancy | Cosmo | Documentation | SDK\n\ninterface Animal {\n class: Class!\n gender: Gender!\n}\n\ninterface Experience {\n yearsOfExperience: Float!\n}\n\nunion Hobby = Exercise | Flying | Gaming | Programming | Travelling | Other\n\ninterface RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\ninterface Identifiable {\n id: Int!\n}\n\ninterface IProduct {\n upc: ID!\n engineers: [Employee!]!\n}\n\ntype Query {\n productTypes: [Products!]!\n findEmployees(criteria: SearchInput): [Employee!]!\n employee(id: Int!): Employee\n employees: [Employee!]!\n products: [Products!]!\n teammates(team: Department!): [Employee!]!\n}\n\nenum ProductName {\n CONSULTANCY\n COSMO\n ENGINE\n FINANCE\n HUMAN_RESOURCES\n MARKETING\n SDK\n}\n\ntype Consultancy {\n upc: ID!\n name: ProductName!\n lead: Employee!\n}\n\ntype Documentation {\n url(product: ProductName!): String!\n urls(products: [ProductName!]!): [String!]!\n}\n\nenum Class {\n FISH\n MAMMAL\n REPTILE\n}\n\nenum Gender {\n FEMALE\n MALE\n UNKNOWN\n}\n\nenum CatType {\n HOME\n STREET\n}\n\nenum DogBreed {\n GOLDEN_RETRIEVER\n POODLE\n ROTTWEILER\n YORKSHIRE_TERRIER\n}\n\nenum MaritalStatus {\n ENGAGED\n MARRIED\n}\n\nenum Nationality {\n AMERICAN\n DUTCH\n ENGLISH\n GERMAN\n INDIAN\n SPANISH\n UKRAINIAN\n}\n\ntype Details {\n forename: String!\n surname: String!\n hasChildren: Boolean!\n maritalStatus: MaritalStatus\n nationality: Nationality!\n pets: [Pet]\n location: Country!\n}\n\ninput SearchInput {\n hasPets: Boolean\n nationality: Nationality\n nested: NestedSearchInput\n}\n\ninput NestedSearchInput {\n maritalStatus: MaritalStatus\n hasChildren: Boolean\n}\n\nenum ExerciseType {\n CALISTHENICS\n HIKING\n SPORT\n STRENGTH_TRAINING\n}\n\ntype Exercise {\n category: ExerciseType!\n}\n\nenum GameGenre {\n ADVENTURE\n BOARD\n FPS\n CARD\n RPG\n ROGUELITE\n SIMULATION\n STRATEGY\n}\n\ntype Other {\n name: String!\n}\n\nenum ProgrammingLanguage {\n CSHARP\n GO\n RUST\n TYPESCRIPT\n}\n\ntype Programming {\n languages: [ProgrammingLanguage!]!\n}\n\nenum Country {\n AMERICA\n ENGLAND\n GERMANY\n INDONESIA\n KOREA\n NETHERLANDS\n PORTUGAL\n SERBIA\n SPAIN\n TAIWAN\n THAILAND\n INDIA\n UKRAINE\n}\n\ntype Travelling {\n countriesLived: [Country!]!\n}\n\ntype Mutation {\n updateEmployeeTag(id: Int!, tag: String!): Employee\n}\n\ntype Subscription {\n \"\"\"`currentTime` will return a stream of `Time` objects.\"\"\"\n currentTime: Time!\n}\n\nenum Department {\n ENGINEERING\n MARKETING\n OPERATIONS\n}\n\nenum EngineerType {\n BACKEND\n FRONTEND\n FULLSTACK\n}\n\nenum OperationType {\n FINANCE\n HUMAN_RESOURCES\n}\n\ntype Time {\n unixTime: Int!\n timeStamp: String!\n}\n\ninterface Pet implements Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Employee implements Identifiable {\n id: Int!\n products: [ProductName!]!\n notes: String!\n details: Details\n hobbies: [Hobby!]!\n tag: String!\n role: RoleType!\n updatedAt: String!\n}\n\ntype Cosmo implements IProduct {\n upc: ID!\n name: ProductName!\n repositoryURL: String!\n engineers: [Employee!]!\n lead: Employee!\n}\n\ntype Alligator implements Pet & Animal {\n class: Class!\n dangerous: String!\n gender: Gender!\n name: String!\n}\n\ntype Cat implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n type: CatType!\n}\n\ntype Dog implements Pet & Animal {\n breed: DogBreed!\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Mouse implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Pony implements Pet & Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Flying implements Experience {\n planeModels: [String!]!\n yearsOfExperience: Float!\n}\n\ntype Gaming implements Experience {\n genres: [GameGenre!]!\n name: String!\n yearsOfExperience: Float!\n}\n\ntype SDK implements IProduct {\n upc: ID!\n clientLanguages: [ProgrammingLanguage!]!\n engineers: [Employee!]!\n owner: Employee!\n}\n\ntype Engineer implements RoleType {\n departments: [Department!]!\n engineerType: EngineerType!\n title: [String!]!\n}\n\ntype Marketer implements RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\ntype Operator implements RoleType {\n departments: [Department!]!\n operatorType: [OperationType!]!\n title: [String!]!\n}","stringStorage":{"28d075e54f235ecd9400d345641679ba46d44720":"schema {\n query: Queries\n}\n\ndirective @composeDirective(name: String!) repeatable on SCHEMA\n\ndirective @eventsPublish(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsRequest(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsSubscribe(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @extends on INTERFACE | OBJECT\n\ndirective @external on FIELD_DEFINITION | OBJECT\n\ndirective @inaccessible on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ndirective @interfaceObject on OBJECT\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: String, import: [String], url: String!) repeatable on SCHEMA\n\ndirective @override(from: String!) on FIELD_DEFINITION\n\ndirective @provides(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @requires(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @shareable on FIELD_DEFINITION | OBJECT\n\ndirective @tag(name: String!) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ntype Consultancy @key(fields: \"upc\") {\n name: ProductName!\n upc: ID!\n}\n\ntype Cosmo @key(fields: \"upc\") {\n name: ProductName!\n repositoryURL: String!\n upc: ID!\n}\n\ntype Documentation {\n url(product: ProductName!): String!\n urls(products: [ProductName!]!): [String!]!\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n notes: String! @override(from: \"employees\")\n products: [ProductName!]!\n}\n\nenum ProductName {\n CONSULTANCY\n COSMO\n ENGINE\n FINANCE\n HUMAN_RESOURCES\n MARKETING\n SDK\n}\n\nunion Products = Consultancy | Cosmo | Documentation\n\ntype Queries {\n productTypes: [Products!]!\n}\n\nscalar openfed__FieldSet","3ff225598d21485cbe809f9a750b57bdfcaf5010":"schema {\n query: Query\n mutation: Mutation\n subscription: Subscription\n}\n\ndirective @composeDirective(name: String!) repeatable on SCHEMA\n\ndirective @eventsPublish(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsRequest(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsSubscribe(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @extends on INTERFACE | OBJECT\n\ndirective @external on FIELD_DEFINITION | OBJECT\n\ndirective @inaccessible on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ndirective @interfaceObject on OBJECT\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: String, import: [String], url: String!) repeatable on SCHEMA\n\ndirective @override(from: String!) on FIELD_DEFINITION\n\ndirective @provides(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @requires(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @shareable on FIELD_DEFINITION | OBJECT\n\ndirective @tag(name: String!) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ntype Consultancy @key(fields: \"upc\") {\n lead: Employee!\n upc: ID!\n}\n\ntype Cosmo implements IProduct @key(fields: \"upc\") {\n engineers: [Employee!]!\n lead: Employee!\n upc: ID!\n}\n\nenum Country {\n AMERICA\n ENGLAND\n GERMANY\n INDIA\n NETHERLANDS\n PORTUGAL\n SPAIN\n UKRAINE\n}\n\nenum Department {\n ENGINEERING\n MARKETING\n OPERATIONS\n}\n\ntype Details @shareable {\n forename: String!\n location: Country!\n surname: String!\n}\n\ntype Employee implements Identifiable @key(fields: \"id\") {\n details: Details! @shareable\n id: Int!\n notes: String\n role: RoleType!\n tag: String!\n updatedAt: String!\n}\n\ntype Engineer implements RoleType {\n departments: [Department!]!\n engineerType: EngineerType!\n title: [String!]!\n}\n\nenum EngineerType {\n BACKEND\n FRONTEND\n FULLSTACK\n}\n\ninterface IProduct {\n engineers: [Employee!]!\n upc: ID!\n}\n\ninterface Identifiable {\n id: Int!\n}\n\ntype Marketer implements RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\ntype Mutation {\n updateEmployeeTag(id: Int!, tag: String!): Employee\n}\n\nenum OperationType {\n FINANCE\n HUMAN_RESOURCES\n}\n\ntype Operator implements RoleType {\n departments: [Department!]!\n operatorType: [OperationType!]!\n title: [String!]!\n}\n\nunion Products = Consultancy | Cosmo | SDK\n\ntype Query {\n employee(id: Int!): Employee\n employees: [Employee!]!\n products: [Products!]!\n teammates(team: Department!): [Employee!]!\n}\n\ninterface RoleType {\n departments: [Department!]!\n title: [String!]!\n}\n\ntype SDK implements IProduct @key(fields: \"upc\") {\n engineers: [Employee!]!\n owner: Employee!\n upc: ID!\n}\n\ntype Subscription {\n \"\"\"`currentTime` will return a stream of `Time` objects.\"\"\"\n currentTime: Time!\n}\n\ntype Time {\n timeStamp: String!\n unixTime: Int!\n}\n\nscalar openfed__FieldSet","8db3c709c4ebcc8bfea331fbfdbefdb300b18d2d":"schema {\n query: Query\n}\n\ndirective @composeDirective(name: String!) repeatable on SCHEMA\n\ndirective @eventsPublish(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsRequest(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsSubscribe(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @extends on INTERFACE | OBJECT\n\ndirective @external on FIELD_DEFINITION | OBJECT\n\ndirective @inaccessible on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ndirective @interfaceObject on OBJECT\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: String, import: [String], url: String!) repeatable on SCHEMA\n\ndirective @override(from: String!) on FIELD_DEFINITION\n\ndirective @provides(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @requires(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @shareable on FIELD_DEFINITION | OBJECT\n\ndirective @tag(name: String!) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\ntype Alligator implements Animal & Pet {\n class: Class!\n dangerous: String!\n gender: Gender!\n name: String!\n}\n\ninterface Animal {\n class: Class!\n gender: Gender!\n}\n\ntype Cat implements Animal & Pet {\n class: Class!\n gender: Gender!\n name: String!\n type: CatType!\n}\n\nenum CatType {\n HOME\n STREET\n}\n\nenum Class {\n FISH\n MAMMAL\n REPTILE\n}\n\ntype Details {\n forename: String! @shareable\n hasChildren: Boolean!\n maritalStatus: MaritalStatus\n nationality: Nationality!\n pets: [Pet]\n surname: String! @shareable\n}\n\ntype Dog implements Animal & Pet {\n breed: DogBreed!\n class: Class!\n gender: Gender!\n name: String!\n}\n\nenum DogBreed {\n GOLDEN_RETRIEVER\n POODLE\n ROTTWEILER\n YORKSHIRE_TERRIER\n}\n\ntype Employee @key(fields: \"id\") {\n details: Details @shareable\n id: Int!\n}\n\nenum Gender {\n FEMALE\n MALE\n UNKNOWN\n}\n\nenum MaritalStatus {\n ENGAGED\n MARRIED\n}\n\ntype Mouse implements Animal & Pet {\n class: Class!\n gender: Gender!\n name: String!\n}\n\nenum Nationality {\n AMERICAN\n DUTCH\n ENGLISH\n GERMAN\n INDIAN\n SPANISH\n UKRAINIAN\n}\n\ninput NestedSearchInput {\n hasChildren: Boolean\n maritalStatus: MaritalStatus\n}\n\ninterface Pet implements Animal {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Pony implements Animal & Pet {\n class: Class!\n gender: Gender!\n name: String!\n}\n\ntype Query {\n findEmployees(criteria: SearchInput): [Employee!]!\n}\n\ninput SearchInput {\n hasPets: Boolean\n nationality: Nationality\n nested: NestedSearchInput\n}\n\nscalar openfed__FieldSet","9424607e5cb88b2944cfa4b9793d2447652731e8":"directive @eventsPublish(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsRequest(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @eventsSubscribe(sourceID: String, topic: String!) on FIELD_DEFINITION\n\ndirective @extends on INTERFACE | OBJECT\n\ndirective @external on FIELD_DEFINITION | OBJECT\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @provides(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @requires(fields: openfed__FieldSet!) on FIELD_DEFINITION\n\ndirective @tag(name: String!) repeatable on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n\nenum Country {\n AMERICA\n ENGLAND\n GERMANY\n INDONESIA\n KOREA\n NETHERLANDS\n PORTUGAL\n SERBIA\n SPAIN\n TAIWAN\n THAILAND\n}\n\ntype Employee @key(fields: \"id\") {\n hobbies: [Hobby!]!\n id: Int!\n}\n\ntype Exercise {\n category: ExerciseType!\n}\n\nenum ExerciseType {\n CALISTHENICS\n HIKING\n SPORT\n STRENGTH_TRAINING\n}\n\ninterface Experience {\n yearsOfExperience: Float!\n}\n\ntype Flying implements Experience {\n planeModels: [String!]!\n yearsOfExperience: Float!\n}\n\nenum GameGenre {\n ADVENTURE\n BOARD\n CARD\n FPS\n ROGUELITE\n RPG\n SIMULATION\n STRATEGY\n}\n\ntype Gaming implements Experience {\n genres: [GameGenre!]!\n name: String!\n yearsOfExperience: Float!\n}\n\nunion Hobby = Exercise | Flying | Gaming | Other | Programming | Travelling\n\ntype Other {\n name: String!\n}\n\ntype Programming {\n languages: [ProgrammingLanguage!]!\n}\n\nenum ProgrammingLanguage {\n CSHARP\n GO\n RUST\n TYPESCRIPT\n}\n\ntype SDK @key(fields: \"upc\") {\n clientLanguages: [ProgrammingLanguage!]!\n upc: ID!\n}\n\ntype Travelling {\n countriesLived: [Country!]!\n}\n\nscalar openfed__FieldSet"}},"version":"1f14e1ca-1945-4406-bfd1-b381db9a9ca0","subgraphs":[{"id":"a6357085-294c-443a-a658-d0a0e18b5ee3","name":"products","routingUrl":"https://product-api.fly.dev/graphql"},{"id":"8fb1f8e6-04dc-4480-b6a6-5109b59f4b6d","name":"family","routingUrl":"https://family-api.fly.dev/graphql"},{"id":"dea1e48a-5b09-4d48-b10d-dcb5663cf582","name":"hobbies","routingUrl":"https://hobbies-api.fly.dev/graphql"},{"id":"a06cbefa-1f19-4e3f-9b9c-74bf3688d1d9","name":"employees","routingUrl":"https://employees-api.fly.dev/graphql"}]} \ No newline at end of file diff --git a/aws-lambda-router/samconfig.toml b/aws-lambda-router/samconfig.toml new file mode 100644 index 0000000000..8f6391239e --- /dev/null +++ b/aws-lambda-router/samconfig.toml @@ -0,0 +1,31 @@ +# More information about the configuration file can be found here: +# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html +version = 0.1 + +[default] +[default.global.parameters] +stack_name = "aws-lambda-router" + +[default.build.parameters] +cached = true +parallel = true + +[default.validate.parameters] +lint = true + +[default.deploy.parameters] +capabilities = "CAPABILITY_IAM" +confirm_changeset = true +resolve_s3 = true + +[default.package.parameters] +resolve_s3 = true + +[default.sync.parameters] +watch = true + +[default.local_start_api.parameters] +warm_containers = "EAGER" + +[default.local_start_lambda.parameters] +warm_containers = "EAGER" diff --git a/aws-lambda-router/template.yaml b/aws-lambda-router/template.yaml new file mode 100644 index 0000000000..cb6f4cd966 --- /dev/null +++ b/aws-lambda-router/template.yaml @@ -0,0 +1,56 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + aws-lambda-router + + Sample SAM Template for the Router + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 30 + #AutoPublishAlias: Prod + MemorySize: 1536 # Minimum to have adequate cold start performance for the router. Reduce to 512 if you can accept the cold start latency or have high traffic. + # ReservedConcurrentExecutions: 30 # This represents the maximum number of concurrent instances allocated to your function. + # To improve cold start performance and latency in general + #ProvisionedConcurrencyConfig: + #ProvisionedConcurrentExecutions: 1 # This is the number of pre-initialized execution environments allocated to your function. These execution environments are ready to respond immediately to incoming function requests. + + # You can add LoggingConfig parameters such as the Logformat, Log Group, and SystemLogLevel or ApplicationLogLevel. Learn more here https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html#sam-function-loggingconfig. + LoggingConfig: + LogFormat: JSON +Resources: + Api: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: cmd + Handler: bootstrap + Runtime: provided.al2023 + Tracing: Active # Enable AWS X-Ray Tracing for Lambda Function + Architectures: + - x86_64 + Events: + RootEndpoint: + Properties: + Method: any + Path: / + Type: Api + EverythingElse: + Properties: + Method: any + Path: /{proxy+} + Type: Api + Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object + Variables: + GRAPH_API_TOKEN: "" # Add your Graph API token here + STAGE: "Prod" # Add the stage e.g. "Prod" if you use the Lambda default endpoint + DEV_MODE: "false" # Set to "true" to enable the dev mode + +Outputs: + Endpoint: + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" + Api: + Description: GraphQL Playground + Value: !GetAtt Api.Arn diff --git a/go.work b/go.work index 9cc7ef2f6b..134a58030b 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,7 @@ go 1.21 use ( + ./aws-lambda-router ./composition-go ./demo ./graphqlmetrics diff --git a/go.work.sum b/go.work.sum index 769438f267..2522cc9967 100644 --- a/go.work.sum +++ b/go.work.sum @@ -335,6 +335,8 @@ github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5 github.com/Microsoft/hcsshim v0.11.0 h1:7EFNIY4igHEXUdj1zXgAyU3fLc7QfOKHbkldRVTBdiM= github.com/Microsoft/hcsshim v0.11.0/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/akrylysov/algnhsa v1.1.0 h1:G0SoP16tMRyiism7VNc3JFA0wq/cVgEkp/ExMVnc6PQ= +github.com/akrylysov/algnhsa v1.1.0/go.mod h1:+bOweRs/WBu5awl+ifCoSYAuKVPAmoTk8XOMrZ1xwiw= github.com/alecthomas/kingpin/v2 v2.3.1 h1:ANLJcKmQm4nIaog7xdr/id6FM6zm5hHnfZrvtKPxqGg= github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE= github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= @@ -347,6 +349,8 @@ github.com/apache/arrow/go/v12 v12.0.0 h1:xtZE63VWl7qLdB0JObIXvvhGjoVNrQ9ciIHG2O github.com/apache/arrow/go/v12 v12.0.0/go.mod h1:d+tV/eHZZ7Dz7RPrFKtPK02tpr+c9/PEd/zm8mDS9Vg= github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/aws/aws-lambda-go v1.43.0 h1:Tdu7SnMB5bD+CbdnSq1Dg4sM68vEuGIDcQFZ+IjUfx0= +github.com/aws/aws-lambda-go v1.43.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -715,6 +719,7 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/graphqlmetrics/cmd/main.go b/graphqlmetrics/cmd/main.go index fb3b374d26..b4c8009060 100644 --- a/graphqlmetrics/cmd/main.go +++ b/graphqlmetrics/cmd/main.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "os/signal" + "sync" "syscall" ) @@ -108,8 +109,10 @@ func main() { logger.Info("Migration is up to date") } + ms := core.NewMetricsService(logger, conn) + svr := core.NewServer( - core.NewMetricsService(logger, conn), + ms, core.WithJwtSecret([]byte(cfg.IngestJWTSecret)), core.WithListenAddr(cfg.ListenAddr), core.WithLogger(logger), @@ -128,6 +131,14 @@ func main() { logger.Info("Graceful shutdown ...", zap.String("shutdown_delay", cfg.ShutdownDelay.String())) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + ms.Shutdown(cfg.ShutdownDelay) + }() + // enforce a maximum shutdown delay ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownDelay) defer cancel() @@ -136,6 +147,9 @@ func main() { logger.Error("Could not shutdown server", zap.Error(err)) } + // Wait for all background tasks to finish (not coupled to the server) + wg.Wait() + logger.Debug("Server exiting") os.Exit(0) } diff --git a/graphqlmetrics/core/metrics_service.go b/graphqlmetrics/core/metrics_service.go index 061fbf348c..64935611ca 100644 --- a/graphqlmetrics/core/metrics_service.go +++ b/graphqlmetrics/core/metrics_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "github.com/ClickHouse/clickhouse-go/v2" + "github.com/alitto/pond" "github.com/avast/retry-go" lru "github.com/hashicorp/golang-lru/v2" graphqlmetricsv1 "github.com/wundergraph/cosmo/graphqlmetrics/gen/proto/wg/cosmo/graphqlmetrics/v1" @@ -18,7 +19,7 @@ import ( var ( errNotAuthenticated = errors.New("authentication didn't succeed") - errPublishFailed = errors.New("failed to publish metrics") + errPublishFailed = errors.New("failed to publish metrics. Please retry") ) type MetricsService struct { @@ -29,6 +30,8 @@ type MetricsService struct { // opGuardCache is used to prevent duplicate writes of the same operation opGuardCache *lru.Cache[string, struct{}] + + pool *pond.WorkerPool } // NewMetricsService creates a new metrics service @@ -41,6 +44,7 @@ func NewMetricsService(logger *zap.Logger, chConn clickhouse.Conn) *MetricsServi logger: logger, conn: chConn, opGuardCache: c, + pool: pond.New(100, 500, pond.MinWorkers(10)), } } @@ -213,48 +217,61 @@ func (s *MetricsService) PublishGraphQLMetrics( return nil, errNotAuthenticated } - var sentMetrics, sentOps = 0, 0 - insertTime := time.Now() + dispatched := s.pool.TrySubmit(func() { + var sentOps, sentMetrics = 0, 0 + insertTime := time.Now() + + defer func() { + requestLogger.Debug("operations write finished", + zap.Duration("duration", time.Since(insertTime)), + zap.Int("metrics", sentMetrics), + zap.Int("operations", sentOps), + ) + }() - defer func() { - requestLogger.Debug("metric write finished", - zap.Duration("duration", time.Since(insertTime)), - zap.Int("metrics", sentMetrics), - zap.Int("operations", sentOps), - ) - }() + insertCtx := context.Background() + + err = retryOnError(insertCtx, requestLogger.With(zap.String("component", "operations")), func(ctx context.Context) error { + writtenOps, err := s.saveOperations(ctx, insertTime, req.Msg.SchemaUsage) + if err != nil { + return err + } + sentOps += writtenOps + return nil + }) - err = retryOnError(ctx, requestLogger.With(zap.String("component", "operations")), func(ctx context.Context) error { - writtenOps, err := s.saveOperations(ctx, insertTime, req.Msg.SchemaUsage) if err != nil { - return err + requestLogger.Error("Failed to write operations", zap.Error(err)) } - sentOps += writtenOps - return nil - }) - if err != nil { - requestLogger.Error("Failed to write operations", zap.Error(err)) - return nil, errPublishFailed - } + err = retryOnError(insertCtx, requestLogger.With(zap.String("component", "metrics")), func(ctx context.Context) error { + writtenMetrics, err := s.saveUsageMetrics(ctx, insertTime, claims, req.Msg.SchemaUsage) + if err != nil { + return err + } + sentMetrics += writtenMetrics + return nil + }) - err = retryOnError(ctx, requestLogger.With(zap.String("component", "metrics")), func(ctx context.Context) error { - writtenMetrics, err := s.saveUsageMetrics(ctx, insertTime, claims, req.Msg.SchemaUsage) if err != nil { - return err + requestLogger.Error("Failed to write metrics", zap.Error(err)) } - sentMetrics += writtenMetrics - return nil }) - if err != nil { - requestLogger.Error("Failed to write metrics", zap.Error(err)) + if !dispatched { + requestLogger.Error("Failed to dispatch request to worker pool") + + // Will force the router to retry the request return nil, errPublishFailed } return res, nil } +func (s *MetricsService) Shutdown(deadline time.Duration) { + s.pool.StopAndWaitFor(deadline) +} + func retryOnError(ctx context.Context, logger *zap.Logger, f func(ctx context.Context) error) error { opts := []retry.Option{ retry.Attempts(3), diff --git a/graphqlmetrics/core/metrics_service_test.go b/graphqlmetrics/core/metrics_service_test.go index 258ffb9ab7..d23fb9b267 100644 --- a/graphqlmetrics/core/metrics_service_test.go +++ b/graphqlmetrics/core/metrics_service_test.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "os" "testing" + "time" ) func TestPublishGraphQLMetrics(t *testing.T) { @@ -70,6 +71,9 @@ func TestPublishGraphQLMetrics(t *testing.T) { ) require.NoError(t, err) + // Wait for batch to be processed + msvc.Shutdown(time.Second * 5) + // Validate insert var opCount uint64 diff --git a/graphqlmetrics/go.mod b/graphqlmetrics/go.mod index ac3742e4d8..f5a20739c5 100644 --- a/graphqlmetrics/go.mod +++ b/graphqlmetrics/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( connectrpc.com/connect v1.11.1 github.com/ClickHouse/clickhouse-go/v2 v2.15.0 + github.com/alitto/pond v1.8.3 github.com/amacneil/dbmate/v2 v2.6.0 github.com/avast/retry-go v3.0.0+incompatible github.com/go-playground/validator/v10 v10.15.5 diff --git a/graphqlmetrics/go.sum b/graphqlmetrics/go.sum index d293ef60cb..d04a56bc18 100644 --- a/graphqlmetrics/go.sum +++ b/graphqlmetrics/go.sum @@ -4,6 +4,8 @@ github.com/ClickHouse/ch-go v0.58.2 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UT github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= github.com/ClickHouse/clickhouse-go/v2 v2.15.0 h1:G0hTKyO8fXXR1bGnZ0DY3vTG01xYfOGW76zgjg5tmC4= github.com/ClickHouse/clickhouse-go/v2 v2.15.0/go.mod h1:kXt1SRq0PIRa6aKZD7TnFnY9PQKmc2b13sHtOYcK6cQ= +github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs= +github.com/alitto/pond v1.8.3/go.mod h1:CmvIIGd5jKLasGI3D87qDkQxjzChdKMmnXMg3fG6M6Q= github.com/amacneil/dbmate/v2 v2.6.0 h1:Me9AOe+AnL/T0yBtdw37DimFuN2Y0/LEYlPItX0FvPE= github.com/amacneil/dbmate/v2 v2.6.0/go.mod h1:avWFrSXhHiBw3/EoaAlgy/ZAtJW0APlNTup3Vqx4jkc= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= diff --git a/lerna.json b/lerna.json index 6d7a5122bb..cedb2e1885 100644 --- a/lerna.json +++ b/lerna.json @@ -26,7 +26,8 @@ "studio", "keycloak", "cdn-server", - "cdn-server/cdn" + "cdn-server/cdn", + "aws-lambda-router" ], "npmClient": "pnpm", "loglevel": "verbose" diff --git a/router-tests/authentication_test.go b/router-tests/authentication_test.go index fc3189e3b8..df56ec0cb9 100644 --- a/router-tests/authentication_test.go +++ b/router-tests/authentication_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/jwks" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/authentication" "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/authentication" ) const ( diff --git a/router-tests/events_test.go b/router-tests/events_test.go index 059a67328c..eae52237b0 100644 --- a/router-tests/events_test.go +++ b/router-tests/events_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/wundergraph/cosmo/router/config" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/hasura/go-graphql-client" "github.com/nats-io/nats.go" diff --git a/router-tests/headers_test.go b/router-tests/headers_test.go index 15bd3d016a..0e89202aed 100644 --- a/router-tests/headers_test.go +++ b/router-tests/headers_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestForwardHeaders(t *testing.T) { diff --git a/router-tests/integration_test.go b/router-tests/integration_test.go index b4636ed469..877fdac444 100644 --- a/router-tests/integration_test.go +++ b/router-tests/integration_test.go @@ -19,7 +19,7 @@ import ( "github.com/buger/jsonparser" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/config" + "github.com/wundergraph/cosmo/router/pkg/config" ) const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/router-tests/modules/module_test.go b/router-tests/modules/module_test.go index 1903340ea4..d10d35f1cf 100644 --- a/router-tests/modules/module_test.go +++ b/router-tests/modules/module_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/wundergraph/cosmo/router/cmd/custom/module" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestModuleSetCustomHeader(t *testing.T) { diff --git a/router-tests/persisted_operations_test.go b/router-tests/persisted_operations_test.go index c16c87238f..0f630310b9 100644 --- a/router-tests/persisted_operations_test.go +++ b/router-tests/persisted_operations_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/config" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestPersistedOperationNotFound(t *testing.T) { diff --git a/router-tests/singleflight_test.go b/router-tests/singleflight_test.go index b7a0584485..05648f175e 100644 --- a/router-tests/singleflight_test.go +++ b/router-tests/singleflight_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestSingleFlight(t *testing.T) { diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 0e9c60eea8..297407a92b 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -29,9 +29,9 @@ import ( "github.com/phayes/freeport" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/demo/pkg/subgraphs" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" "go.uber.org/atomic" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -260,11 +260,11 @@ func createTestEnv(t testing.TB, cfg *Config) (*Environment, error) { return nil, err } - svr, err := rr.NewTestServer(ctx) + svr, err := rr.NewServer(ctx) require.NoError(t, err) go func() { - if err := svr.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := svr.HttpServer().ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { t.Errorf("could not start router: %s", err) } }() diff --git a/router-tests/websocket_test.go b/router-tests/websocket_test.go index fb5cb06876..cd0d459e7e 100644 --- a/router-tests/websocket_test.go +++ b/router-tests/websocket_test.go @@ -14,7 +14,7 @@ import ( "github.com/hasura/go-graphql-client/pkg/jsonutil" "github.com/stretchr/testify/require" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/config" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestWebSockets(t *testing.T) { diff --git a/router/cmd/instance.go b/router/cmd/instance.go index 1009ae15fb..c087a07e67 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -5,15 +5,15 @@ import ( "github.com/wundergraph/cosmo/router/internal/cdn" "github.com/wundergraph/cosmo/router/internal/controlplane/configpoller" "github.com/wundergraph/cosmo/router/internal/controlplane/selfregister" + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/cors" + "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/trace" "go.uber.org/automaxprocs/maxprocs" - "github.com/wundergraph/cosmo/router/authentication" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/internal/handler/cors" - "github.com/wundergraph/cosmo/router/internal/metric" - "github.com/wundergraph/cosmo/router/internal/trace" "go.uber.org/zap" ) diff --git a/router/cmd/main.go b/router/cmd/main.go index b0187a92a9..ac0515664e 100644 --- a/router/cmd/main.go +++ b/router/cmd/main.go @@ -3,17 +3,17 @@ package cmd import ( "context" "flag" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/logging" "log" "os" "os/signal" "syscall" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/core" "go.uber.org/zap" - "github.com/wundergraph/cosmo/router/internal/logging" "github.com/wundergraph/cosmo/router/internal/profile" ) diff --git a/router/core/access_controller.go b/router/core/access_controller.go index 8d8a58a033..af15b144b7 100644 --- a/router/core/access_controller.go +++ b/router/core/access_controller.go @@ -2,9 +2,8 @@ package core import ( "errors" + "github.com/wundergraph/cosmo/router/pkg/authentication" "net/http" - - "github.com/wundergraph/cosmo/router/authentication" ) var ( diff --git a/router/core/context.go b/router/core/context.go index 3325ecd427..ca00180c60 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -2,6 +2,8 @@ package core import ( "context" + "github.com/wundergraph/cosmo/router/pkg/authentication" + ctrace "github.com/wundergraph/cosmo/router/pkg/trace" "net/http" "net/url" "sync" @@ -9,9 +11,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "go.uber.org/zap" - - "github.com/wundergraph/cosmo/router/authentication" - ctrace "github.com/wundergraph/cosmo/router/internal/trace" ) type key string diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index bb69c847bf..4fce1bc161 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "github.com/wundergraph/cosmo/router/pkg/config" "net/http" "net/url" "time" @@ -15,7 +16,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/staticdatasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/cosmo/router/config" "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/pubsub" diff --git a/router/core/graphql_handler.go b/router/core/graphql_handler.go index 7cee08d371..28d99af191 100644 --- a/router/core/graphql_handler.go +++ b/router/core/graphql_handler.go @@ -3,6 +3,8 @@ package core import ( "context" "errors" + "github.com/wundergraph/cosmo/router/pkg/logging" + "github.com/wundergraph/cosmo/router/pkg/otel" "net" "net/http" "strings" @@ -13,8 +15,6 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" - "github.com/wundergraph/cosmo/router/internal/logging" - "github.com/wundergraph/cosmo/router/internal/otel" "github.com/wundergraph/cosmo/router/internal/pool" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" diff --git a/router/core/graphql_prehandler.go b/router/core/graphql_prehandler.go index 4d18074dbe..ed682f8c32 100644 --- a/router/core/graphql_prehandler.go +++ b/router/core/graphql_prehandler.go @@ -1,10 +1,14 @@ package core import ( + "context" "crypto/ecdsa" "errors" "fmt" + "github.com/wundergraph/cosmo/router/pkg/logging" + sdktrace "go.opentelemetry.io/otel/sdk/trace" "net/http" + "sync" "time" "github.com/go-chi/chi/middleware" @@ -12,7 +16,6 @@ import ( "go.uber.org/zap" "github.com/wundergraph/cosmo/router/internal/cdn" - "github.com/wundergraph/cosmo/router/internal/logging" "github.com/wundergraph/cosmo/router/internal/pool" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" @@ -20,40 +23,46 @@ import ( ) type PreHandlerOptions struct { - Logger *zap.Logger - Executor *Executor - Metrics *RouterMetrics - Parser *OperationParser - Planner *OperationPlanner - AccessController *AccessController - DevelopmentMode bool - RouterPublicKey *ecdsa.PublicKey - EnableRequestTracing bool + Logger *zap.Logger + Executor *Executor + Metrics RouterMetrics + Parser *OperationParser + Planner *OperationPlanner + AccessController *AccessController + DevelopmentMode bool + RouterPublicKey *ecdsa.PublicKey + EnableRequestTracing bool + TracerProvider *sdktrace.TracerProvider + FlushTelemetryAfterResponse bool } type PreHandler struct { - log *zap.Logger - executor *Executor - metrics *RouterMetrics - parser *OperationParser - planner *OperationPlanner - accessController *AccessController - developmentMode bool - routerPublicKey *ecdsa.PublicKey - enableRequestTracing bool + log *zap.Logger + executor *Executor + metrics RouterMetrics + parser *OperationParser + planner *OperationPlanner + accessController *AccessController + developmentMode bool + routerPublicKey *ecdsa.PublicKey + enableRequestTracing bool + tracerProvider *sdktrace.TracerProvider + flushTelemetryAfterResponse bool } func NewPreHandler(opts *PreHandlerOptions) *PreHandler { return &PreHandler{ - log: opts.Logger, - executor: opts.Executor, - metrics: opts.Metrics, - parser: opts.Parser, - planner: opts.Planner, - accessController: opts.AccessController, - routerPublicKey: opts.RouterPublicKey, - developmentMode: opts.DevelopmentMode, - enableRequestTracing: opts.EnableRequestTracing, + log: opts.Logger, + executor: opts.Executor, + metrics: opts.Metrics, + parser: opts.Parser, + planner: opts.Planner, + accessController: opts.AccessController, + routerPublicKey: opts.RouterPublicKey, + developmentMode: opts.DevelopmentMode, + enableRequestTracing: opts.EnableRequestTracing, + flushTelemetryAfterResponse: opts.FlushTelemetryAfterResponse, + tracerProvider: opts.TracerProvider, } } @@ -83,6 +92,11 @@ func (h *PreHandler) Handler(next http.Handler) http.Handler { clientInfo := NewClientInfoFromRequest(r) metrics := h.metrics.StartOperation(clientInfo, requestLogger, r.ContentLength) + + if h.flushTelemetryAfterResponse { + defer h.flushMetrics(r.Context(), requestLogger) + } + defer func() { metrics.Finish(hasRequestError, statusCode, writtenBytes) }() @@ -195,6 +209,43 @@ func (h *PreHandler) Handler(next http.Handler) http.Handler { }) } +func (h *PreHandler) flushMetrics(ctx context.Context, requestLogger *zap.Logger) { + requestLogger.Debug("Flushing metrics ...") + + now := time.Now() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := h.metrics.GqlMetricsExporter().ForceFlush(ctx); err != nil { + requestLogger.Error("Failed to flush schema usage metrics", zap.Error(err)) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + if err := h.metrics.MetricStore().ForceFlush(ctx); err != nil { + requestLogger.Error("Failed to flush OTEL metrics", zap.Error(err)) + } + }() + + if h.tracerProvider != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := h.tracerProvider.ForceFlush(ctx); err != nil { + requestLogger.Error("Failed to flush OTEL tracer", zap.Error(err)) + } + }() + } + + wg.Wait() + + requestLogger.Debug("Metrics flushed", zap.Duration("duration", time.Since(now))) +} + func (h *PreHandler) writeOperationError(w http.ResponseWriter, r *http.Request, requestLogger *zap.Logger, err error) { var reportErr ReportError var inputErr InputError diff --git a/router/core/header_rule_engine.go b/router/core/header_rule_engine.go index cb79ba0e24..18841e8a1a 100644 --- a/router/core/header_rule_engine.go +++ b/router/core/header_rule_engine.go @@ -2,10 +2,10 @@ package core import ( "fmt" + "github.com/wundergraph/cosmo/router/pkg/config" "net/http" "regexp" - "github.com/wundergraph/cosmo/router/config" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" ) diff --git a/router/core/header_rule_engine_test.go b/router/core/header_rule_engine_test.go index 837093c386..a49cadd429 100644 --- a/router/core/header_rule_engine_test.go +++ b/router/core/header_rule_engine_test.go @@ -3,13 +3,13 @@ package core import ( "fmt" "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router/pkg/config" "net/http" "net/http/httptest" "net/url" "testing" "github.com/stretchr/testify/assert" - "github.com/wundergraph/cosmo/router/config" "go.uber.org/zap" ) diff --git a/router/core/modules.go b/router/core/modules.go index ea47afd25f..592c5c8814 100644 --- a/router/core/modules.go +++ b/router/core/modules.go @@ -81,16 +81,16 @@ type EnginePostOriginHandler interface { OnOriginResponse(resp *http.Response, ctx RequestContext) *http.Response } -// Provisioner is called before the Server starts +// Provisioner is called before the server starts // It allows you to initialize your module e.g. create a database connection // or load a configuration file type Provisioner interface { - // Provision is called before the Server starts + // Provision is called before the server starts Provision(*ModuleContext) error } type Cleaner interface { - // Cleanup is called after the Server stops + // Cleanup is called after the server stops Cleanup() error } diff --git a/router/core/operation_metrics.go b/router/core/operation_metrics.go index 0def000a01..6daefadb77 100644 --- a/router/core/operation_metrics.go +++ b/router/core/operation_metrics.go @@ -2,18 +2,14 @@ package core import ( "context" + "github.com/wundergraph/cosmo/router/pkg/otel" "strconv" "time" "go.uber.org/zap" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" - graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" - "github.com/wundergraph/cosmo/router/internal/graphqlmetrics" - "github.com/wundergraph/cosmo/router/internal/metric" - "github.com/wundergraph/cosmo/router/internal/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -31,119 +27,24 @@ func (p OperationProtocol) String() string { type OperationMetrics struct { requestContentLength int64 - metrics metric.Store + routerMetrics RouterMetrics operationStartTime time.Time metricBaseFields []attribute.KeyValue inflightMetric func() - gqlMetricsExporter *graphqlmetrics.Exporter routerConfigVersion string opContext *operationContext logger *zap.Logger } func (m *OperationMetrics) exportSchemaUsageInfo(operationContext *operationContext, statusCode int, hasError bool) { - if m.gqlMetricsExporter == nil { - return - } - - usageInfo, err := plan.GetSchemaUsageInfo( - operationContext.preparedPlan.preparedPlan, - operationContext.preparedPlan.operationDocument, - operationContext.preparedPlan.schemaDocument, - operationContext.Variables(), - ) - if err != nil { - m.logger.Error("failed to get schema usage info", zap.Error(err)) - return - } - - fieldUsageInfos := make([]*graphqlmetricsv1.TypeFieldUsageInfo, len(usageInfo.TypeFields)) - argumentUsageInfos := make([]*graphqlmetricsv1.ArgumentUsageInfo, len(usageInfo.Arguments)) - inputUsageInfos := make([]*graphqlmetricsv1.InputUsageInfo, len(usageInfo.InputTypeFields)) - - for i := range usageInfo.TypeFields { - fieldUsageInfos[i] = &graphqlmetricsv1.TypeFieldUsageInfo{ - Count: 1, - Path: usageInfo.TypeFields[i].Path, - TypeNames: usageInfo.TypeFields[i].EnclosingTypeNames, - SubgraphIDs: usageInfo.TypeFields[i].Source.IDs, - NamedType: usageInfo.TypeFields[i].FieldTypeName, - } - } - - for i := range usageInfo.Arguments { - argumentUsageInfos[i] = &graphqlmetricsv1.ArgumentUsageInfo{ - Count: 1, - Path: []string{usageInfo.Arguments[i].FieldName, usageInfo.Arguments[i].ArgumentName}, - TypeName: usageInfo.Arguments[i].EnclosingTypeName, - NamedType: usageInfo.Arguments[i].ArgumentTypeName, - } - } - - for i := range usageInfo.InputTypeFields { - // In that case it is a top level input field usage e.g employee(id: 1) - if len(usageInfo.InputTypeFields[i].EnclosingTypeNames) == 0 { - inputUsageInfos[i] = &graphqlmetricsv1.InputUsageInfo{ - Count: uint64(usageInfo.InputTypeFields[i].Count), - NamedType: usageInfo.InputTypeFields[i].FieldTypeName, - // Root input fields have no enclosing type name and no path - } - } else { - inputUsageInfos[i] = &graphqlmetricsv1.InputUsageInfo{ - Path: []string{usageInfo.InputTypeFields[i].EnclosingTypeNames[0], usageInfo.InputTypeFields[i].FieldName}, - Count: uint64(usageInfo.InputTypeFields[i].Count), - TypeName: usageInfo.InputTypeFields[i].EnclosingTypeNames[0], - NamedType: usageInfo.InputTypeFields[i].FieldTypeName, - } - } - } - - var opType graphqlmetricsv1.OperationType - switch operationContext.opType { - case "query": - opType = graphqlmetricsv1.OperationType_QUERY - case "mutation": - opType = graphqlmetricsv1.OperationType_MUTATION - case "subscription": - opType = graphqlmetricsv1.OperationType_SUBSCRIPTION - } - - // Non-blocking - m.gqlMetricsExporter.Record(&graphqlmetricsv1.SchemaUsageInfo{ - RequestDocument: operationContext.content, - TypeFieldMetrics: fieldUsageInfos, - OperationInfo: &graphqlmetricsv1.OperationInfo{ - Type: opType, - Hash: strconv.FormatUint(operationContext.hash, 10), - Name: operationContext.name, - }, - SchemaInfo: &graphqlmetricsv1.SchemaInfo{ - Version: m.routerConfigVersion, - }, - ClientInfo: &graphqlmetricsv1.ClientInfo{ - Name: operationContext.clientInfo.Name, - Version: operationContext.clientInfo.Version, - }, - ArgumentMetrics: argumentUsageInfos, - InputMetrics: inputUsageInfos, - RequestInfo: &graphqlmetricsv1.RequestInfo{ - Error: hasError, - StatusCode: int32(statusCode), - }, - }) + m.routerMetrics.ExportSchemaUsageInfo(operationContext, statusCode, hasError) } func (m *OperationMetrics) AddOperationContext(opContext *operationContext) { - if m == nil { - return - } m.opContext = opContext } func (m *OperationMetrics) Finish(hasErrored bool, statusCode int, responseSize int) { - if m == nil { - return - } m.inflightMetric() ctx := context.Background() @@ -153,14 +54,16 @@ func (m *OperationMetrics) Finish(hasErrored bool, statusCode int, responseSize m.metricBaseFields = append(m.metricBaseFields, otel.WgRequestError.Bool(hasErrored)) } + rm := m.routerMetrics.MetricStore() + m.metricBaseFields = append(m.metricBaseFields, semconv.HTTPStatusCode(statusCode)) - m.metrics.MeasureRequestCount(ctx, m.metricBaseFields...) - m.metrics.MeasureRequestSize(ctx, m.requestContentLength, m.metricBaseFields...) - m.metrics.MeasureLatency(ctx, + rm.MeasureRequestCount(ctx, m.metricBaseFields...) + rm.MeasureRequestSize(ctx, m.requestContentLength, m.metricBaseFields...) + rm.MeasureLatency(ctx, m.operationStartTime, m.metricBaseFields..., ) - m.metrics.MeasureResponseSize(ctx, int64(responseSize), m.metricBaseFields...) + rm.MeasureResponseSize(ctx, int64(responseSize), m.metricBaseFields...) if m.opContext != nil { m.exportSchemaUsageInfo(m.opContext, statusCode, hasErrored) @@ -168,37 +71,32 @@ func (m *OperationMetrics) Finish(hasErrored bool, statusCode int, responseSize } func (m *OperationMetrics) AddAttributes(kv ...attribute.KeyValue) { - if m == nil { - return - } m.metricBaseFields = append(m.metricBaseFields, kv...) } // AddClientInfo adds the client info to the operation metrics. If OperationMetrics // is nil, it's a no-op. func (m *OperationMetrics) AddClientInfo(info *ClientInfo) { - if m == nil { + if info == nil { return } - // Add client info to metrics base fields m.metricBaseFields = append(m.metricBaseFields, otel.WgClientName.String(info.Name)) m.metricBaseFields = append(m.metricBaseFields, otel.WgClientVersion.String(info.Version)) } // startOperationMetrics starts the metrics for an operation. This should only be called by -// RouterMetrics.StartOperation() -func startOperationMetrics(metricStore metric.Store, logger *zap.Logger, requestContentLength int64, gqlMetricsExporter *graphqlmetrics.Exporter, routerConfigVersion string) *OperationMetrics { +// routerMetrics.StartOperation() +func startOperationMetrics(rMetrics RouterMetrics, logger *zap.Logger, requestContentLength int64, routerConfigVersion string) *OperationMetrics { operationStartTime := time.Now() - inflightMetric := metricStore.MeasureInFlight(context.Background()) + inflightMetric := rMetrics.MetricStore().MeasureInFlight(context.Background()) return &OperationMetrics{ requestContentLength: requestContentLength, - metrics: metricStore, operationStartTime: operationStartTime, inflightMetric: inflightMetric, - gqlMetricsExporter: gqlMetricsExporter, routerConfigVersion: routerConfigVersion, + routerMetrics: rMetrics, logger: logger, } } diff --git a/router/core/router.go b/router/core/router.go index 7848972d9e..6c5aed0f6b 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -5,6 +5,15 @@ import ( "crypto/ecdsa" "errors" "fmt" + "github.com/wundergraph/cosmo/router/internal/recoveryhandler" + "github.com/wundergraph/cosmo/router/internal/requestlogger" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/cors" + "github.com/wundergraph/cosmo/router/pkg/health" + rmetric "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/otel" + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" + rtrace "github.com/wundergraph/cosmo/router/pkg/trace" "net" "net/http" "net/url" @@ -22,24 +31,14 @@ import ( "github.com/wundergraph/cosmo/router/internal/graphqlmetrics" brotli "go.withmatt.com/connect-brotli" - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" - "github.com/dgraph-io/ristretto" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/mitchellh/mapstructure" - "github.com/wundergraph/cosmo/router/config" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/health" "github.com/wundergraph/cosmo/router/internal/graphiql" - "github.com/wundergraph/cosmo/router/internal/handler/cors" - "github.com/wundergraph/cosmo/router/internal/handler/recovery" - "github.com/wundergraph/cosmo/router/internal/handler/requestlogger" - "github.com/wundergraph/cosmo/router/internal/metric" - "github.com/wundergraph/cosmo/router/internal/otel" "github.com/wundergraph/cosmo/router/internal/retrytransport" "github.com/wundergraph/cosmo/router/internal/stringsx" - "github.com/wundergraph/cosmo/router/internal/trace" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -52,9 +51,8 @@ type ( // Router is the main application instance. Router struct { Config - activeRouter *Server + activeServer *server modules []Module - mu sync.Mutex WebsocketStats WebSocketsStatistics } @@ -77,18 +75,20 @@ type ( // Config defines the configuration options for the Router. Config struct { logger *zap.Logger - traceConfig *trace.Config - metricConfig *metric.Config + traceConfig *rtrace.Config + metricConfig *rmetric.Config tracerProvider *sdktrace.TracerProvider otlpMeterProvider *sdkmetric.MeterProvider promMeterProvider *sdkmetric.MeterProvider - gqlMetricsExporter *graphqlmetrics.Exporter + gqlMetricsExporter graphqlmetrics.SchemaUsageExporter corsOptions *cors.Config routerConfig *nodev1.RouterConfig gracePeriod time.Duration + awsLambda bool shutdown bool listenAddr string baseURL string + graphqlWebURL string graphqlPath string playground bool introspection bool @@ -128,10 +128,15 @@ type ( overrideRoutingURLConfiguration config.OverrideRoutingURLConfiguration } - // Server is the main router instance. - Server struct { + Server interface { + HttpServer() *http.Server + HealthChecks() health.Checker + } + + // server is the main router instance. + server struct { Config - Server *http.Server + server *http.Server // rootContext that all services depending on the router should // use as a parent context rootContext context.Context @@ -140,12 +145,12 @@ type ( healthChecks health.Checker } - // Option defines the method to customize Server. + // Option defines the method to customize server. Option func(svr *Router) ) // NewRouter creates a new Router instance. Router.Start() must be called to start the server. -// Alternatively, use Router.NewTestServer() to create a new Server instance without starting it for testing purposes. +// Alternatively, use Router.NewServer() to create a new server instance without starting it. func NewRouter(opts ...Option) (*Router, error) { r := &Router{ WebsocketStats: NewNoopWebSocketStats(), @@ -164,14 +169,18 @@ func NewRouter(opts ...Option) (*Router, error) { r.graphqlPath = "/graphql" } + if r.graphqlWebURL == "" { + r.graphqlWebURL = r.graphqlPath + } + // Default values for trace and metric config if r.traceConfig == nil { - r.traceConfig = trace.DefaultConfig(Version) + r.traceConfig = rtrace.DefaultConfig(Version) } if r.metricConfig == nil { - r.metricConfig = metric.DefaultConfig(Version) + r.metricConfig = rmetric.DefaultConfig(Version) } if r.corsOptions == nil { @@ -250,7 +259,7 @@ func NewRouter(opts ...Option) (*Router, error) { if r.traceConfig.Enabled && len(r.traceConfig.Exporters) == 0 { if endpoint := otelconfig.DefaultEndpoint(); endpoint != "" { r.logger.Debug("Using default trace exporter", zap.String("endpoint", endpoint)) - r.traceConfig.Exporters = append(r.traceConfig.Exporters, &trace.Exporter{ + r.traceConfig.Exporters = append(r.traceConfig.Exporters, &rtrace.Exporter{ Endpoint: endpoint, Exporter: otelconfig.ExporterOLTPHTTP, HTTPPath: "/v1/traces", @@ -263,7 +272,7 @@ func NewRouter(opts ...Option) (*Router, error) { if r.metricConfig.OpenTelemetry.Enabled && len(r.metricConfig.OpenTelemetry.Exporters) == 0 { if endpoint := otelconfig.DefaultEndpoint(); endpoint != "" { r.logger.Debug("Using default metrics exporter", zap.String("endpoint", endpoint)) - r.metricConfig.OpenTelemetry.Exporters = append(r.metricConfig.OpenTelemetry.Exporters, &metric.OpenTelemetryExporter{ + r.metricConfig.OpenTelemetry.Exporters = append(r.metricConfig.OpenTelemetry.Exporters, &rmetric.OpenTelemetryExporter{ Endpoint: endpoint, Exporter: otelconfig.ExporterOLTPHTTP, HTTPPath: "/v1/metrics", @@ -286,14 +295,14 @@ func NewRouter(opts ...Option) (*Router, error) { } if r.traceConfig.Enabled { - defaultExporter := trace.GetDefaultExporter(r.traceConfig) + defaultExporter := rtrace.GetDefaultExporter(r.traceConfig) if defaultExporter != nil { disabledFeatures = append(disabledFeatures, "Cosmo Cloud Tracing") defaultExporter.Disabled = true } } if r.metricConfig.OpenTelemetry.Enabled { - defaultExporter := metric.GetDefaultExporter(r.metricConfig) + defaultExporter := rmetric.GetDefaultExporter(r.metricConfig) if defaultExporter != nil { disabledFeatures = append(disabledFeatures, "Cosmo Cloud Metrics") defaultExporter.Disabled = true @@ -334,7 +343,7 @@ func (r *Router) configureSubgraphOverwrites(cfg *nodev1.RouterConfig) ([]Subgra Name: sg.Name, } - // Validate subgraph url. Note that that it can be empty if the subgraph is virtual + // Validate subgraph url. Note that it can be empty if the subgraph is virtual parsedURL, err := url.Parse(sg.RoutingUrl) if err != nil { return nil, fmt.Errorf("failed to parse subgraph url '%s': %w", sg.RoutingUrl, err) @@ -370,32 +379,40 @@ func (r *Router) configureSubgraphOverwrites(cfg *nodev1.RouterConfig) ([]Subgra return subgraphs, nil } -// updateServer starts a new Server. It swaps the active Server with a new Server instance when the config has changed. -// This method is safe for concurrent use. When the router can't be swapped due to an error the old server kept running. -func (r *Router) updateServer(ctx context.Context, cfg *nodev1.RouterConfig) error { - // Rebuild Server with new router config - // In case of an error, we return early and keep the old Server running - newRouter, err := r.newServer(ctx, cfg) +// UpdateServer starts a new server and swaps the active server with the new one. The old server is shutdown gracefully. +// When the router can't be swapped due to an error the old server kept running. Not safe for concurrent use. +func (r *Router) UpdateServer(ctx context.Context, cfg *nodev1.RouterConfig) (Server, error) { + // Rebuild server with new router config + // In case of an error, we return early and keep the old server running + newServer, err := r.newServer(ctx, cfg) if err != nil { r.logger.Error("Failed to create a new router instance. Keeping old router running", zap.Error(err)) - return err + return nil, err } - prevRouter := r.activeRouter - - if prevRouter != nil { - if err := prevRouter.Shutdown(ctx); err != nil { + if r.activeServer != nil { + if err := r.activeServer.Shutdown(ctx); err != nil { r.logger.Error("Could not shutdown router", zap.Error(err)) - return err + return nil, err } } - // Swap active Server - r.mu.Lock() - r.activeRouter = newRouter - r.mu.Unlock() + // Swap active server + r.activeServer = newServer + + return newServer, nil +} + +func (r *Router) updateServerAndStart(ctx context.Context, cfg *nodev1.RouterConfig) error { - // Start new Server + if _, err := r.UpdateServer(ctx, cfg); err != nil { + return err + } + + // read here to avoid race condition + version := r.activeServer.routerConfig.GetVersion() + + // Start new server go func() { r.logger.Info("Server listening", zap.String("listen_addr", r.listenAddr), @@ -404,15 +421,15 @@ func (r *Router) updateServer(ctx context.Context, cfg *nodev1.RouterConfig) err zap.String("config_version", cfg.GetVersion()), ) - r.activeRouter.healthChecks.SetReady(true) + r.activeServer.healthChecks.SetReady(true) // This is a blocking call - if err := r.activeRouter.listenAndServe(); err != nil { - r.activeRouter.healthChecks.SetReady(true) + if err := r.activeServer.listenAndServe(); err != nil { + r.activeServer.healthChecks.SetReady(true) r.logger.Error("Failed to start new server", zap.Error(err)) } - r.logger.Info("Server stopped", zap.String("config_version", newRouter.routerConfig.GetVersion())) + r.logger.Info("Server stopped", zap.String("config_version", version)) }() return nil @@ -475,28 +492,47 @@ func (r *Router) initModules(ctx context.Context) error { return nil } -// NewTestServer prepares a new Server instance but does not start it. The method should be only used for testing purposes. -// Use core.WithStaticRouterConfig to pass the initial config otherwise the engine will error. -func (r *Router) NewTestServer(ctx context.Context) (*Server, error) { +// NewServer prepares a new server instance but does not start it. The method should only be used when you want to bootstrap +// the server manually otherwise you can use Router.Start(). You're responsible for setting health checks status to ready with Server.HealthChecks(). +// The server can be shutdown with Router.Shutdown(). Use core.WithStaticRouterConfig to pass the initial config otherwise the Router will +// try to fetch the config from the control plane. You can swap the router config by using Router.UpdateServer(). +func (r *Router) NewServer(ctx context.Context) (Server, error) { + if r.shutdown { + return nil, fmt.Errorf("router is shutdown. Create a new instance with router.NewRouter()") + } + if err := r.bootstrap(ctx); err != nil { return nil, fmt.Errorf("failed to bootstrap application: %w", err) } - newRouter, err := r.newServer(ctx, r.routerConfig) + // Start the server with the static config without polling + if r.routerConfig != nil { + r.logger.Info("Static router config provided. Polling is disabled. Updating router config is only possible by providing a config.") + return r.UpdateServer(ctx, r.routerConfig) + } + + // when no static config is provided and no poller is configured, we can't start the server + if r.configPoller == nil { + return nil, fmt.Errorf("config fetcher not provided. Please provide a static router config instead") + } + + routerConfig, err := r.configPoller.GetRouterConfig(ctx) if err != nil { - r.logger.Error("Failed to create new server", zap.Error(err)) - return nil, err + return nil, fmt.Errorf("failed to get initial router config: %w", err) } - r.activeRouter = newRouter + if _, err := r.UpdateServer(ctx, routerConfig); err != nil { + r.logger.Error("Failed to start server with initial config", zap.Error(err)) + return nil, err + } - return newRouter, nil + return r.activeServer, nil } -// bootstrap initializes the Router. It is called by Start() and NewTestServer(). +// bootstrap initializes the Router. It is called by Start() and NewServer(). // It should only be called once for a Router instance. func (r *Router) bootstrap(ctx context.Context) error { - cosmoCloudTracingEnabled := r.traceConfig.Enabled && trace.GetDefaultExporter(r.traceConfig) != nil + cosmoCloudTracingEnabled := r.traceConfig.Enabled && rtrace.GetDefaultExporter(r.traceConfig) != nil artInProductionEnabled := r.engineExecutionConfiguration.EnableRequestTracing && !r.developmentMode needsRegistration := cosmoCloudTracingEnabled || artInProductionEnabled @@ -524,7 +560,7 @@ func (r *Router) bootstrap(ctx context.Context) error { } if r.traceConfig.Enabled { - tp, err := trace.NewTracerProvider(ctx, r.logger, r.traceConfig) + tp, err := rtrace.NewTracerProvider(ctx, r.logger, r.traceConfig) if err != nil { return fmt.Errorf("failed to start trace agent: %w", err) } @@ -534,25 +570,27 @@ func (r *Router) bootstrap(ctx context.Context) error { // Prometheus metrics rely on OTLP metrics if r.metricConfig.IsEnabled() { if r.metricConfig.Prometheus.Enabled { - mp, registry, err := metric.NewPrometheusMeterProvider(ctx, r.metricConfig) + mp, registry, err := rmetric.NewPrometheusMeterProvider(ctx, r.metricConfig) if err != nil { return fmt.Errorf("failed to create Prometheus exporter: %w", err) } r.promMeterProvider = mp - r.prometheusServer = metric.ServePrometheus(r.logger, r.metricConfig.Prometheus.ListenAddr, r.metricConfig.Prometheus.Path, registry) + r.prometheusServer = rmetric.ServePrometheus(r.logger, r.metricConfig.Prometheus.ListenAddr, r.metricConfig.Prometheus.Path, registry) go func() { if err := r.prometheusServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { r.logger.Error("Failed to start Prometheus server", zap.Error(err)) } }() } - mp, err := metric.NewOtlpMeterProvider(ctx, r.logger, r.metricConfig) + mp, err := rmetric.NewOtlpMeterProvider(ctx, r.logger, r.metricConfig) if err != nil { return fmt.Errorf("failed to start trace agent: %w", err) } r.otlpMeterProvider = mp } + r.gqlMetricsExporter = graphqlmetrics.NewNoopExporter() + if r.graphqlMetricsConfig.Enabled { client := graphqlmetricsv1connect.NewGraphQLMetricsServiceClient( http.DefaultClient, @@ -561,17 +599,16 @@ func (r *Router) bootstrap(ctx context.Context) error { // Compress requests with Brotli. connect.WithSendCompression(brotli.Name), ) - r.gqlMetricsExporter = graphqlmetrics.NewExporter( + ge, err := graphqlmetrics.NewExporter( r.logger, client, r.graphApiToken, graphqlmetrics.NewDefaultExporterSettings(), ) - if err := r.gqlMetricsExporter.Validate(); err != nil { + if err != nil { return fmt.Errorf("failed to validate graphql metrics exporter: %w", err) } - - r.gqlMetricsExporter.Start() + r.gqlMetricsExporter = ge r.logger.Info("GraphQL schema coverage metrics enabled") } @@ -592,8 +629,7 @@ func (r *Router) bootstrap(ctx context.Context) error { return nil } -// Start starts the Server. It blocks until the context is cancelled or when the initial config could not be fetched -// from the control plane. All bootstrapping logic should be done in bootstrap(). +// Start starts the server. It does not block. The server can be shutdown with Router.Shutdown(). func (r *Router) Start(ctx context.Context) error { if r.shutdown { return fmt.Errorf("router is shutdown. Create a new instance with router.NewRouter()") @@ -606,7 +642,7 @@ func (r *Router) Start(ctx context.Context) error { // Start the server with the static config without polling if r.routerConfig != nil { r.logger.Info("Static router config provided. Polling is disabled. Updating router config is only possible by providing a config.") - return r.updateServer(ctx, r.routerConfig) + return r.updateServerAndStart(ctx, r.routerConfig) } // when no static config is provided and no poller is configured, we can't start the server @@ -619,7 +655,7 @@ func (r *Router) Start(ctx context.Context) error { return fmt.Errorf("failed to get initial router config: %w", err) } - if err := r.updateServer(ctx, routerConfig); err != nil { + if err := r.updateServerAndStart(ctx, routerConfig); err != nil { r.logger.Error("Failed to start server with initial config", zap.Error(err)) return err } @@ -631,7 +667,7 @@ func (r *Router) Start(ctx context.Context) error { zap.String("old_version", oldVersion), zap.String("new_version", newConfig.GetVersion()), ) - if err := r.updateServer(ctx, newConfig); err != nil { + if err := r.updateServerAndStart(ctx, newConfig); err != nil { r.logger.Error("Failed to start server with new config. Trying again on the next update cycle.", zap.Error(err)) return err } @@ -641,26 +677,26 @@ func (r *Router) Start(ctx context.Context) error { return nil } -// newServer creates a new Server instance. +// newServer creates a new server instance. // All stateful data is copied from the Router over to the new server instance. -func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfig) (*Server, error) { +func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfig) (*server, error) { subgraphs, err := r.configureSubgraphOverwrites(routerConfig) if err != nil { return nil, err } rootContext, rootContextCancel := context.WithCancel(ctx) - ro := &Server{ + ro := &server{ rootContext: rootContext, rootContextCancel: rootContextCancel, routerConfig: routerConfig, Config: r.Config, } - recoveryHandler := recovery.New(recovery.WithLogger(r.logger), recovery.WithPrintStack()) - var traceHandler *trace.Middleware + recoveryHandler := recoveryhandler.New(recoveryhandler.WithLogger(r.logger), recoveryhandler.WithPrintStack()) + var traceHandler *rtrace.Middleware if r.traceConfig.Enabled { - traceHandler = trace.NewMiddleware(otel.RouterServerAttribute, + traceHandler = rtrace.NewMiddleware(otel.RouterServerAttribute, otelhttp.WithSpanOptions( oteltrace.WithAttributes( otel.WgRouterGraphName.String(r.federatedGraphName), @@ -668,8 +704,8 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi otel.WgRouterVersion.String(Version), ), ), - otelhttp.WithFilter(trace.CommonRequestFilter), - otelhttp.WithFilter(trace.PrefixRequestFilter( + otelhttp.WithFilter(rtrace.CommonRequestFilter), + otelhttp.WithFilter(rtrace.PrefixRequestFilter( []string{r.healthCheckPath, r.readinessCheckPath, r.livenessCheckPath}), ), // Disable built-in metricStore through NoopMeterProvider @@ -681,6 +717,7 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi requestLogger := requestlogger.New( r.logger, requestlogger.WithDefaultOptions(), + requestlogger.WithNoTimeField(), requestlogger.WithContext(func(request *http.Request) []zapcore.Field { return []zapcore.Field{ zap.String("config_version", routerConfig.GetVersion()), @@ -738,17 +775,17 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi r.logger.Info("localhost fallback enabled, connections that fail to connect to localhost will be retried using host.docker.internal") } - metricStore := metric.NewNoopMetrics() + metricStore := rmetric.NewNoopMetrics() // Prometheus metricStore rely on OTLP metricStore if r.metricConfig.IsEnabled() { - m, err := metric.NewMetrics( + m, err := rmetric.NewMetrics( r.metricConfig.Name, Version, - metric.WithPromMeterProvider(r.promMeterProvider), - metric.WithOtlpMeterProvider(r.otlpMeterProvider), - metric.WithLogger(r.logger), - metric.WithAttributes( + rmetric.WithPromMeterProvider(r.promMeterProvider), + rmetric.WithOtlpMeterProvider(r.otlpMeterProvider), + rmetric.WithLogger(r.logger), + rmetric.WithAttributes( otel.WgRouterGraphName.String(r.federatedGraphName), otel.WgRouterConfigVersion.String(routerConfig.GetVersion()), otel.WgRouterVersion.String(Version), @@ -761,7 +798,13 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi metricStore = m } - routerMetrics := NewRouterMetrics(metricStore, r.gqlMetricsExporter, routerConfig.GetVersion(), r.logger) + routerMetrics := NewRouterMetrics(&routerMetricsConfig{ + metrics: metricStore, + gqlMetricsExporter: r.gqlMetricsExporter, + exportEnabled: r.graphqlMetricsConfig.Enabled, + routerConfigVersion: routerConfig.GetVersion(), + logger: r.logger, + }) transport := newHTTPTransport(r.subgraphTransportOptions) @@ -831,7 +874,7 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi graphqlPlaygroundHandler = graphiql.NewPlayground(&graphiql.PlaygroundOptions{ Log: r.logger, Html: graphiql.PlaygroundHTML(), - GraphqlURL: r.graphqlPath, + GraphqlURL: r.graphqlWebURL, }) } @@ -852,15 +895,17 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi } graphqlPreHandler := NewPreHandler(&PreHandlerOptions{ - Logger: r.logger, - Executor: executor, - Metrics: routerMetrics, - Parser: operationParser, - Planner: operationPlanner, - AccessController: r.accessController, - RouterPublicKey: publicKey, - EnableRequestTracing: r.engineExecutionConfiguration.EnableRequestTracing, - DevelopmentMode: r.developmentMode, + Logger: r.logger, + Executor: executor, + Metrics: routerMetrics, + Parser: operationParser, + Planner: operationPlanner, + AccessController: r.accessController, + RouterPublicKey: publicKey, + EnableRequestTracing: r.engineExecutionConfiguration.EnableRequestTracing, + DevelopmentMode: r.developmentMode, + TracerProvider: r.tracerProvider, + FlushTelemetryAfterResponse: r.awsLambda, }) wsMiddleware := NewWebsocketMiddleware(rootContext, WebsocketMiddlewareOptions{ @@ -903,7 +948,7 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi zap.String("url", r.baseURL+r.graphqlPath), ) - ro.Server = &http.Server{ + ro.server = &http.Server{ Addr: r.listenAddr, // https://ieftimov.com/posts/make-resilient-golang-net-http-servers-using-timeouts-deadlines-context-cancellation/ ReadTimeout: 1 * time.Minute, @@ -916,9 +961,9 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi return ro, nil } -// listenAndServe starts the Server and blocks until the Server is shutdown. -func (r *Server) listenAndServe() error { - if err := r.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { +// listenAndServe starts the server and blocks until the server is shutdown. +func (r *server) listenAndServe() error { + if err := r.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } @@ -941,8 +986,8 @@ func (r *Router) Shutdown(ctx context.Context) (err error) { } } - if r.activeRouter != nil { - if subErr := r.activeRouter.Shutdown(ctx); subErr != nil { + if r.activeServer != nil { + if subErr := r.activeServer.Shutdown(ctx); subErr != nil { err = errors.Join(err, fmt.Errorf("failed to shutdown primary server: %w", subErr)) } } @@ -1001,8 +1046,8 @@ func (r *Router) Shutdown(ctx context.Context) (err error) { return err } -// Shutdown gracefully shutdown the Server. -func (r *Server) Shutdown(ctx context.Context) (err error) { +// Shutdown gracefully shutdown the server. +func (r *server) Shutdown(ctx context.Context) (err error) { r.logger.Info("Gracefully shutting down the router ...", zap.String("config_version", r.routerConfig.GetVersion()), zap.String("grace_period", r.gracePeriod.String()), @@ -1018,8 +1063,9 @@ func (r *Server) Shutdown(ctx context.Context) (err error) { r.healthChecks.SetReady(false) - if r.Server != nil { - if err := r.Server.Shutdown(ctx); err != nil { + if r.server != nil { + // HTTP server shutdown + if err := r.server.Shutdown(ctx); err != nil { return err } } @@ -1027,6 +1073,14 @@ func (r *Server) Shutdown(ctx context.Context) (err error) { return err } +func (r *server) HealthChecks() health.Checker { + return r.healthChecks +} + +func (r *server) HttpServer() *http.Server { + return r.server +} + func WithListenerAddr(addr string) Option { return func(r *Router) { r.listenAddr = addr @@ -1051,7 +1105,7 @@ func WithIntrospection(enable bool) Option { } } -func WithTracing(cfg *trace.Config) Option { +func WithTracing(cfg *rtrace.Config) Option { return func(r *Router) { r.traceConfig = cfg } @@ -1063,9 +1117,19 @@ func WithCors(corsOpts *cors.Config) Option { } } -func WithGraphQLPath(path string) Option { +// WithGraphQLPath sets the path to the GraphQL endpoint. +func WithGraphQLPath(p string) Option { + return func(r *Router) { + r.graphqlPath = p + } +} + +// WithGraphQLWebURL sets the URL to the GraphQL endpoint used by the GraphQL Playground. +// This is useful when the path differs from the actual GraphQL endpoint e.g. when the router is behind a reverse proxy. +// If not set, the GraphQL Playground uses the same URL as the GraphQL endpoint. +func WithGraphQLWebURL(p string) Option { return func(r *Router) { - r.graphqlPath = path + r.graphqlWebURL = p } } @@ -1087,12 +1151,13 @@ func WithGracePeriod(timeout time.Duration) Option { } } -func WithMetrics(cfg *metric.Config) Option { +func WithMetrics(cfg *rmetric.Config) Option { return func(r *Router) { r.metricConfig = cfg } } +// WithFederatedGraphName sets the federated graph name. It is used to get the latest config from the control plane. func WithFederatedGraphName(name string) Option { return func(r *Router) { r.federatedGraphName = name @@ -1131,6 +1196,14 @@ func WithStaticRouterConfig(cfg *nodev1.RouterConfig) Option { } } +// WithAwsLambdaRuntime enables the AWS Lambda behaviour. +// This flushes all telemetry data synchronously after the request is handled. +func WithAwsLambdaRuntime() Option { + return func(r *Router) { + r.awsLambda = true + } +} + func WithHealthCheckPath(path string) Option { return func(r *Router) { r.healthCheckPath = path diff --git a/router/core/router_metrics.go b/router/core/router_metrics.go index f86d027307..442e2c71dd 100644 --- a/router/core/router_metrics.go +++ b/router/core/router_metrics.go @@ -1,50 +1,69 @@ package core import ( + "github.com/wundergraph/cosmo/router/pkg/metric" "strconv" graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" "github.com/wundergraph/cosmo/router/internal/graphqlmetrics" - "github.com/wundergraph/cosmo/router/internal/metric" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "go.uber.org/zap" ) -// RouterMetrics encapsulates all data and configuration that the router +type RouterMetrics interface { + StartOperation(clientInfo *ClientInfo, logger *zap.Logger, requestContentLength int64) *OperationMetrics + ExportSchemaUsageInfo(operationContext *operationContext, statusCode int, hasError bool) + GqlMetricsExporter() graphqlmetrics.SchemaUsageExporter + MetricStore() metric.Store +} + +// routerMetrics encapsulates all data and configuration that the router // uses to collect and its metrics -type RouterMetrics struct { +type routerMetrics struct { + metrics metric.Store + gqlMetricsExporter graphqlmetrics.SchemaUsageExporter + routerConfigVersion string + logger *zap.Logger + exportEnabled bool +} + +type routerMetricsConfig struct { metrics metric.Store - gqlMetricsExporter *graphqlmetrics.Exporter + gqlMetricsExporter graphqlmetrics.SchemaUsageExporter routerConfigVersion string logger *zap.Logger + exportEnabled bool +} + +func NewRouterMetrics(cfg *routerMetricsConfig) RouterMetrics { + return &routerMetrics{ + metrics: cfg.metrics, + gqlMetricsExporter: cfg.gqlMetricsExporter, + routerConfigVersion: cfg.routerConfigVersion, + logger: cfg.logger, + exportEnabled: cfg.exportEnabled, + } } // StartOperation starts the metrics for a new GraphQL operation. The returned value is a OperationMetrics // where the caller must always call Finish() (usually via defer()). If the metrics are disabled, this // returns nil, but OperationMetrics is safe to call with a nil receiver. -func (m *RouterMetrics) StartOperation(clientInfo *ClientInfo, logger *zap.Logger, requestContentLength int64) *OperationMetrics { - if m == nil || m.metrics == nil { - // Return a nil OperationMetrics, which will be a no-op, to simplify callers - return nil - } - metrics := startOperationMetrics(m.metrics, logger, requestContentLength, m.gqlMetricsExporter, m.routerConfigVersion) - if clientInfo != nil { - metrics.AddClientInfo(clientInfo) - } +func (m *routerMetrics) StartOperation(clientInfo *ClientInfo, logger *zap.Logger, requestContentLength int64) *OperationMetrics { + metrics := startOperationMetrics(m, logger, requestContentLength, m.routerConfigVersion) + metrics.AddClientInfo(clientInfo) return metrics } -func NewRouterMetrics(metrics metric.Store, gqlMetrics *graphqlmetrics.Exporter, configVersion string, logger *zap.Logger) *RouterMetrics { - return &RouterMetrics{ - metrics: metrics, - gqlMetricsExporter: gqlMetrics, - routerConfigVersion: configVersion, - logger: logger, - } +func (m *routerMetrics) MetricStore() metric.Store { + return m.metrics +} + +func (m *routerMetrics) GqlMetricsExporter() graphqlmetrics.SchemaUsageExporter { + return m.gqlMetricsExporter } -func (m *RouterMetrics) ExportSchemaUsageInfo(operationContext *operationContext, statusCode int, hasError bool) { - if m.gqlMetricsExporter == nil { +func (m *routerMetrics) ExportSchemaUsageInfo(operationContext *operationContext, statusCode int, hasError bool) { + if !m.exportEnabled { return } diff --git a/router/core/transport.go b/router/core/transport.go index 8e3471b03e..a5a1ede502 100644 --- a/router/core/transport.go +++ b/router/core/transport.go @@ -3,7 +3,9 @@ package core import ( "bytes" "fmt" - "github.com/wundergraph/cosmo/router/internal/metric" + "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/otel" + "github.com/wundergraph/cosmo/router/pkg/trace" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" "io" "net/http" @@ -13,9 +15,7 @@ import ( "time" "github.com/wundergraph/cosmo/router/internal/docker" - "github.com/wundergraph/cosmo/router/internal/otel" "github.com/wundergraph/cosmo/router/internal/retrytransport" - "github.com/wundergraph/cosmo/router/internal/trace" "github.com/wundergraph/cosmo/router/internal/unsafebytes" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/pool" diff --git a/router/core/websocket.go b/router/core/websocket.go index 578eba1fd9..428f6a8f8c 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -7,7 +7,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/wundergraph/cosmo/router/internal/logging" + "github.com/wundergraph/cosmo/router/pkg/logging" "net" "net/http" "sync" @@ -37,7 +37,7 @@ type WebsocketMiddlewareOptions struct { Parser *OperationParser Planner *OperationPlanner GraphQLHandler *GraphQLHandler - Metrics *RouterMetrics + Metrics RouterMetrics AccessController *AccessController Logger *zap.Logger Stats WebSocketsStatistics @@ -142,7 +142,7 @@ type WebsocketHandler struct { parser *OperationParser planner *OperationPlanner graphqlHandler *GraphQLHandler - metrics *RouterMetrics + metrics RouterMetrics accessController *AccessController logger *zap.Logger @@ -486,7 +486,7 @@ type WebSocketConnectionHandlerOptions struct { Parser *OperationParser Planner *OperationPlanner GraphQLHandler *GraphQLHandler - Metrics *RouterMetrics + Metrics RouterMetrics ResponseWriter http.ResponseWriter Request *http.Request Connection *wsConnectionWrapper @@ -504,7 +504,7 @@ type WebSocketConnectionHandler struct { parser *OperationParser planner *OperationPlanner graphqlHandler *GraphQLHandler - metrics *RouterMetrics + metrics RouterMetrics w http.ResponseWriter r *http.Request conn *wsConnectionWrapper diff --git a/router/internal/graphqlmetrics/batch_queue.go b/router/internal/graphqlmetrics/batch_queue.go index 2face2c4bd..230c95d5d3 100644 --- a/router/internal/graphqlmetrics/batch_queue.go +++ b/router/internal/graphqlmetrics/batch_queue.go @@ -31,14 +31,37 @@ func (o *BatchQueueOptions) ensureDefaults() { } } +// QueueWork The interface that any item in the queue must implement. +type QueueWork[T any] interface { + Item() T + Flush() chan struct{} +} + +// QueueItem is the base type for queue items. It implements the QueueWork interface. +type QueueItem[T any] struct { + item T + flushed chan struct{} +} + +func (e *QueueItem[T]) Item() T { + return e.item +} + +// Flush returns a channel that marks a batch as ready to be dispatched once the item is received. +// On the consumer side this channel can be closed and used as a signal that all prior items were dispatched. +// We use it in the graphqlmetrics exporter to implement flushing. +func (e *QueueItem[T]) Flush() chan struct{} { + return e.flushed +} + // BatchQueue coordinates dispatching of queue items by time intervals // or immediately after the batching limit is met. Items are enqueued without blocking // and all items are buffered unless the queue is stopped forcefully with the stop() context. type BatchQueue[T any] struct { config *BatchQueueOptions timer *time.Timer - inQueue chan T - OutQueue chan []T + inQueue chan QueueWork[T] + OutQueue chan []QueueWork[T] } // NewBatchQueue returns an initialized instance of BatchQueue. @@ -53,15 +76,15 @@ func NewBatchQueue[T any](config *BatchQueueOptions) *BatchQueue[T] { bq := &BatchQueue[T]{ config: config, - inQueue: make(chan T, config.MaxQueueSize), - OutQueue: make(chan []T, config.MaxQueueSize/config.MaxBatchItems), + inQueue: make(chan QueueWork[T], config.MaxQueueSize), + OutQueue: make(chan []QueueWork[T], config.MaxQueueSize/config.MaxBatchItems), } return bq } // Enqueue adds an item to the queue. Returns false if the queue is stopped or not ready to accept items -func (b *BatchQueue[T]) Enqueue(item T) bool { +func (b *BatchQueue[T]) Enqueue(item QueueWork[T]) bool { select { case b.inQueue <- item: return true @@ -78,11 +101,12 @@ func (b *BatchQueue[T]) tick() { func (b *BatchQueue[T]) dispatch() { for { - var items []T + var items []QueueWork[T] var stopped bool for { select { + // Dispatch after interval case <-b.timer.C: goto done case item, ok := <-b.inQueue: @@ -94,6 +118,11 @@ func (b *BatchQueue[T]) dispatch() { items = append(items, item) + // process batch immediately after flush marker was received + if item.Flush() != nil { + goto done + } + // batch limit reached, dispatch if len(items) == b.config.MaxBatchItems { goto done diff --git a/router/internal/graphqlmetrics/batch_queue_test.go b/router/internal/graphqlmetrics/batch_queue_test.go index dfa7c4e767..ce9f4eda58 100644 --- a/router/internal/graphqlmetrics/batch_queue_test.go +++ b/router/internal/graphqlmetrics/batch_queue_test.go @@ -13,7 +13,7 @@ func printf(s string, a ...interface{}) { } } -func produce(q chan string, numItems, numGoroutines int, out chan []string) { +func produce(q chan QueueWork[string], numItems, numGoroutines int, out chan []string) { printf("=== Producing %d items.\n", numItems*numGoroutines) done := make(chan bool, 1) msgs := make(chan string) @@ -24,7 +24,7 @@ func produce(q chan string, numItems, numGoroutines int, out chan []string) { go func(routineIdx int) { for j := 0; j < numItems; j++ { m := fmt.Sprintf("producer#%d, item#%d", routineIdx, j) - q <- m + q <- &QueueItem[string]{item: m} msgs <- m } wg.Done() @@ -68,7 +68,7 @@ func TestDispatchOrder(t *testing.T) { produced := make(chan []string, 1) go produce(b.inQueue, numItems, numGoroutines, produced) - var dispatched []string + var dispatched []QueueWork[string] breakout := make(chan bool) time.AfterFunc(time.Duration(30)*time.Millisecond, func() { breakout <- true @@ -96,7 +96,7 @@ L: msgExists[msgs[i]] = false } for i := 0; i < len(dispatched); i++ { - if _, ok := msgExists[dispatched[i]]; !ok { + if _, ok := msgExists[dispatched[i].Item()]; !ok { t.Errorf("item was not dispatched: %s", dispatched[i]) } } diff --git a/router/internal/graphqlmetrics/exporter.go b/router/internal/graphqlmetrics/exporter.go index 1584f2c080..78879c3d54 100644 --- a/router/internal/graphqlmetrics/exporter.go +++ b/router/internal/graphqlmetrics/exporter.go @@ -10,18 +10,40 @@ import ( "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1/graphqlmetricsv1connect" "go.uber.org/zap" "sync" + "sync/atomic" "time" ) +type SchemaUsageInfo struct { + item *graphqlmetricsv12.SchemaUsageInfo + flushed chan struct{} +} + +func (e *SchemaUsageInfo) Item() *graphqlmetricsv12.SchemaUsageInfo { + return e.item +} + +func (e *SchemaUsageInfo) Flush() chan struct{} { + return e.flushed +} + type Exporter struct { queue *BatchQueue[*graphqlmetricsv12.SchemaUsageInfo] settings *ExporterSettings logger *zap.Logger - outQueue <-chan []*graphqlmetricsv12.SchemaUsageInfo + outQueue <-chan []QueueWork[*graphqlmetricsv12.SchemaUsageInfo] stopWG sync.WaitGroup client graphqlmetricsv1connect.GraphQLMetricsServiceClient apiToken string cancelShutdown context.CancelFunc + stopOnce sync.Once + stopped atomic.Bool +} + +type SchemaUsageExporter interface { + Record(item *graphqlmetricsv12.SchemaUsageInfo) bool + ForceFlush(ctx context.Context) error + Shutdown(ctx context.Context) error } type RetryOptions struct { @@ -73,7 +95,7 @@ func NewDefaultExporterSettings() *ExporterSettings { // NewExporter creates a new GraphQL metrics exporter. The collectorEndpoint is the endpoint to which the metrics // are sent. The apiToken is the token used to authenticate with the collector. The collector supports Brotli compression // and retries on failure. Underling queue implementation sends batches of metrics at the specified interval and batch size. -func NewExporter(logger *zap.Logger, client graphqlmetricsv1connect.GraphQLMetricsServiceClient, apiToken string, settings *ExporterSettings) *Exporter { +func NewExporter(logger *zap.Logger, client graphqlmetricsv1connect.GraphQLMetricsServiceClient, apiToken string, settings *ExporterSettings) (SchemaUsageExporter, error) { bq := NewBatchQueue[*graphqlmetricsv12.SchemaUsageInfo](&BatchQueueOptions{ Interval: settings.Interval, @@ -81,17 +103,23 @@ func NewExporter(logger *zap.Logger, client graphqlmetricsv1connect.GraphQLMetri MaxQueueSize: settings.QueueSize, }) - return &Exporter{ + e := &Exporter{ queue: bq, outQueue: bq.OutQueue, logger: logger.With(zap.String("component", "graphqlmetrics_exporter")), settings: settings, client: client, apiToken: apiToken, + stopOnce: sync.Once{}, + stopped: atomic.Bool{}, } + + e.start() + + return e, e.validate() } -func (e *Exporter) Validate() error { +func (e *Exporter) validate() error { if e.settings.BatchSize <= 0 { return errors.New("batch size must be positive") } @@ -129,7 +157,14 @@ func (e *Exporter) Validate() error { // Record records the items as potential metrics to be exported. func (e *Exporter) Record(item *graphqlmetricsv12.SchemaUsageInfo) bool { - if !e.queue.Enqueue(item) { + // Do not enqueue new items if exporter is already stopped + if e.stopped.Load() { + return false + } + + if !e.queue.Enqueue(&SchemaUsageInfo{ + item: item, + }) { e.logger.Warn("Drop tracking schema usage due to full queue. Please increase the queue size or decrease the batch size.") return false } @@ -216,8 +251,8 @@ func (e *Exporter) export(ctx context.Context, batch []*graphqlmetricsv12.Schema return lastErr } -// Start starts the exporter. -func (e *Exporter) Start() { +// start starts the exporter and blocks until the exporter is shutdown. +func (e *Exporter) start() { var startWG sync.WaitGroup e.queue.Start() @@ -239,8 +274,22 @@ func (e *Exporter) Start() { case <-shutdownCtx.Done(): return case batch, more := <-e.outQueue: + if more { - _ = e.export(shutdownCtx, Aggregate(batch)) + items := make([]*graphqlmetricsv12.SchemaUsageInfo, 0, len(batch)) + // The flushed marker is used to signal that the batch has been processed + for _, item := range batch { + if item.Item() != nil { + items = append(items, item.Item()) + } + if ffs := item.Flush(); ffs != nil { + close(ffs) + continue + } + } + if len(items) > 0 { + _ = e.export(shutdownCtx, Aggregate(items)) + } } else { // Close current exporter when queues was closed from producer side return @@ -253,21 +302,52 @@ func (e *Exporter) Start() { startWG.Wait() } +func (e *Exporter) ForceFlush(ctx context.Context) error { + // Interrupt if context is already canceled. + if err := ctx.Err(); err != nil { + return err + } + + // Do not wait for queue to be empty if exporter is already stopped + if e.stopped.Load() { + return nil + } + + flushed := make(chan struct{}) + + // Enqueue a flush marker item + e.queue.Enqueue(&QueueItem[*graphqlmetricsv12.SchemaUsageInfo]{ + flushed: flushed, + }) + + select { + case <-flushed: + // Processed any items in queue prior to ForceFlush being called + case <-ctx.Done(): + return ctx.Err() + } + + return nil +} + // Shutdown the exporter but waits until all export jobs has been finished or timeout. // If the context is canceled, the exporter will be shutdown immediately. func (e *Exporter) Shutdown(ctx context.Context) error { - // stop dispatching new items - e.queue.Stop() + e.stopOnce.Do(func() { + e.stopped.Store(true) + // stop dispatching new items and close the queue + e.queue.Stop() - go func() { - // cancel consumers immediately without waiting for the queue to be empty - <-ctx.Done() - e.cancelShutdown() - }() + go func() { + // cancel consumers immediately without waiting for the queue to be empty + <-ctx.Done() + e.cancelShutdown() + }() - // wait for all items to be processed - e.stopWG.Wait() + // wait for all items to be processed + e.stopWG.Wait() + }) return nil } diff --git a/router/internal/graphqlmetrics/exporter_test.go b/router/internal/graphqlmetrics/exporter_test.go index 0e6d7ebed2..4961bbfe9e 100644 --- a/router/internal/graphqlmetrics/exporter_test.go +++ b/router/internal/graphqlmetrics/exporter_test.go @@ -36,7 +36,7 @@ func TestExportAggregationSameSchemaUsages(t *testing.T) { totalItems := 100 batchSize := 100 - e := NewExporter( + e, err := NewExporter( zap.NewNop(), c, "secret", @@ -55,9 +55,7 @@ func TestExportAggregationSameSchemaUsages(t *testing.T) { }, ) - require.Nil(t, e.Validate()) - - e.Start() + require.Nil(t, err) for i := 0; i < totalItems; i++ { @@ -115,7 +113,7 @@ func TestExportBatchesWithUniqueSchemaUsages(t *testing.T) { totalItems := 100 batchSize := 5 - e := NewExporter( + e, err := NewExporter( zap.NewNop(), c, "secret", @@ -134,9 +132,7 @@ func TestExportBatchesWithUniqueSchemaUsages(t *testing.T) { }, ) - require.Nil(t, e.Validate()) - - e.Start() + require.Nil(t, err) for i := 0; i < totalItems; i++ { usage := &graphqlmetricsv1.SchemaUsageInfo{ @@ -178,6 +174,124 @@ func TestExportBatchesWithUniqueSchemaUsages(t *testing.T) { require.Equal(t, 5, len(c.publishedBatches[0])) } +func TestForceFlushSync(t *testing.T) { + c := &MyClient{ + t: t, + publishedBatches: make([][]*graphqlmetricsv1.SchemaUsageInfo, 0), + } + + queueSize := 100 + totalItems := 10 + batchSize := 5 + + e, err := NewExporter( + zap.NewNop(), + c, + "secret", + &ExporterSettings{ + NumConsumers: 1, + BatchSize: batchSize, + QueueSize: queueSize, + // Intentionally set to a high value to make sure that the exporter is forced to flush immediately + Interval: 5000 * time.Millisecond, + ExportTimeout: 5000 * time.Millisecond, + Retry: RetryOptions{ + Enabled: false, + MaxDuration: 300 * time.Millisecond, + Interval: 100 * time.Millisecond, + MaxRetry: 3, + }, + }, + ) + + require.Nil(t, err) + + for i := 0; i < totalItems; i++ { + usage := &graphqlmetricsv1.SchemaUsageInfo{ + TypeFieldMetrics: []*graphqlmetricsv1.TypeFieldUsageInfo{ + { + Path: []string{"user", "id"}, + TypeNames: []string{"User", "ID"}, + SubgraphIDs: []string{"1", "2"}, + Count: 2, + }, + { + Path: []string{"user", "name"}, + TypeNames: []string{"User", "String"}, + SubgraphIDs: []string{"1", "2"}, + Count: 1, + }, + }, + OperationInfo: &graphqlmetricsv1.OperationInfo{ + Type: graphqlmetricsv1.OperationType_QUERY, + Hash: fmt.Sprintf("hash-%d", i), + Name: "user", + }, + ClientInfo: &graphqlmetricsv1.ClientInfo{ + Name: "wundergraph", + Version: "1.0.0", + }, + SchemaInfo: &graphqlmetricsv1.SchemaInfo{ + Version: "1", + }, + Attributes: map[string]string{}, + } + + require.True(t, e.Record(usage)) + } + + require.Nil(t, e.ForceFlush(context.Background())) + + require.Equal(t, totalItems/batchSize, len(c.publishedBatches)) + require.Equal(t, 2, len(c.publishedBatches)) + require.Equal(t, 5, len(c.publishedBatches[0])) + + // Make sure that the exporter is still working after a forced flush + + // Reset the published batches + c.publishedBatches = c.publishedBatches[:0] + + for i := 0; i < totalItems; i++ { + usage := &graphqlmetricsv1.SchemaUsageInfo{ + TypeFieldMetrics: []*graphqlmetricsv1.TypeFieldUsageInfo{ + { + Path: []string{"user", "id"}, + TypeNames: []string{"User", "ID"}, + SubgraphIDs: []string{"1", "2"}, + Count: 2, + }, + { + Path: []string{"user", "name"}, + TypeNames: []string{"User", "String"}, + SubgraphIDs: []string{"1", "2"}, + Count: 1, + }, + }, + OperationInfo: &graphqlmetricsv1.OperationInfo{ + Type: graphqlmetricsv1.OperationType_QUERY, + Hash: fmt.Sprintf("hash-%d", i), + Name: "user", + }, + ClientInfo: &graphqlmetricsv1.ClientInfo{ + Name: "wundergraph", + Version: "1.0.0", + }, + SchemaInfo: &graphqlmetricsv1.SchemaInfo{ + Version: "1", + }, + Attributes: map[string]string{}, + } + + require.True(t, e.Record(usage)) + } + + require.Nil(t, e.ForceFlush(context.Background())) + + require.Equal(t, totalItems/batchSize, len(c.publishedBatches)) + require.Equal(t, 2, len(c.publishedBatches)) + require.Equal(t, 5, len(c.publishedBatches[0])) +} + func TestExportBatchInterval(t *testing.T) { c := &MyClient{ t: t, @@ -188,7 +302,7 @@ func TestExportBatchInterval(t *testing.T) { totalItems := 5 batchSize := 10 - e := NewExporter( + e, err := NewExporter( zap.NewNop(), c, "secret", @@ -207,9 +321,7 @@ func TestExportBatchInterval(t *testing.T) { }, ) - require.Nil(t, e.Validate()) - - e.Start() + require.Nil(t, err) for i := 0; i < totalItems; i++ { usage := &graphqlmetricsv1.SchemaUsageInfo{ @@ -262,7 +374,7 @@ func TestExportFullQueue(t *testing.T) { totalItems := 100 batchSize := 1 - e := NewExporter( + e, err := NewExporter( zap.NewNop(), c, "secret", @@ -281,9 +393,7 @@ func TestExportFullQueue(t *testing.T) { }, ) - require.Nil(t, e.Validate()) - - e.Start() + require.Nil(t, err) var dispatched int diff --git a/router/internal/graphqlmetrics/noop_exporter.go b/router/internal/graphqlmetrics/noop_exporter.go new file mode 100644 index 0000000000..54acfe7157 --- /dev/null +++ b/router/internal/graphqlmetrics/noop_exporter.go @@ -0,0 +1,24 @@ +package graphqlmetrics + +import ( + "context" + graphqlmetricsv12 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" +) + +type NoopExporter struct{} + +func NewNoopExporter() *NoopExporter { + return &NoopExporter{} +} + +func (e *NoopExporter) Record(_ *graphqlmetricsv12.SchemaUsageInfo) bool { + return true +} + +func (e *NoopExporter) ForceFlush(_ context.Context) error { + return nil +} + +func (e *NoopExporter) Shutdown(_ context.Context) error { + return nil +} diff --git a/router/internal/handler/recovery/recovery.go b/router/internal/recoveryhandler/recovery.go similarity index 99% rename from router/internal/handler/recovery/recovery.go rename to router/internal/recoveryhandler/recovery.go index 9859551193..af3846f483 100644 --- a/router/internal/handler/recovery/recovery.go +++ b/router/internal/recoveryhandler/recovery.go @@ -1,4 +1,4 @@ -package recovery +package recoveryhandler import ( "net" diff --git a/router/internal/handler/recovery/recovery_test.go b/router/internal/recoveryhandler/recovery_test.go similarity index 96% rename from router/internal/handler/recovery/recovery_test.go rename to router/internal/recoveryhandler/recovery_test.go index 4a5ae18072..d430fb2937 100644 --- a/router/internal/handler/recovery/recovery_test.go +++ b/router/internal/recoveryhandler/recovery_test.go @@ -1,4 +1,4 @@ -package recovery +package recoveryhandler import ( "github.com/wundergraph/cosmo/router/internal/test" diff --git a/router/internal/handler/requestlogger/requestlogger.go b/router/internal/requestlogger/requestlogger.go similarity index 95% rename from router/internal/handler/requestlogger/requestlogger.go rename to router/internal/requestlogger/requestlogger.go index f692363fd0..53f9547458 100644 --- a/router/internal/handler/requestlogger/requestlogger.go +++ b/router/internal/requestlogger/requestlogger.go @@ -42,6 +42,13 @@ func WithContext(fn Fn) Option { } } +func WithNoTimeField() Option { + return func(r *handler) { + r.timeFormat = "" + r.utc = false + } +} + func WithDefaultOptions() Option { return func(r *handler) { r.timeFormat = time.RFC3339 diff --git a/router/internal/handler/requestlogger/requestlogger_test.go b/router/internal/requestlogger/requestlogger_test.go similarity index 95% rename from router/internal/handler/requestlogger/requestlogger_test.go rename to router/internal/requestlogger/requestlogger_test.go index 58c7d3cd54..60a3a30c8c 100644 --- a/router/internal/handler/requestlogger/requestlogger_test.go +++ b/router/internal/requestlogger/requestlogger_test.go @@ -5,8 +5,8 @@ import ( "bytes" "encoding/json" "github.com/stretchr/testify/assert" - "github.com/wundergraph/cosmo/router/internal/logging" "github.com/wundergraph/cosmo/router/internal/test" + "github.com/wundergraph/cosmo/router/pkg/logging" "go.uber.org/zap" "go.uber.org/zap/zapcore" "net/http" diff --git a/router/authentication/authentication.go b/router/pkg/authentication/authentication.go similarity index 100% rename from router/authentication/authentication.go rename to router/pkg/authentication/authentication.go diff --git a/router/authentication/context.go b/router/pkg/authentication/context.go similarity index 100% rename from router/authentication/context.go rename to router/pkg/authentication/context.go diff --git a/router/authentication/doc.go b/router/pkg/authentication/doc.go similarity index 100% rename from router/authentication/doc.go rename to router/pkg/authentication/doc.go diff --git a/router/authentication/http.go b/router/pkg/authentication/http.go similarity index 100% rename from router/authentication/http.go rename to router/pkg/authentication/http.go diff --git a/router/authentication/jwks.go b/router/pkg/authentication/jwks.go similarity index 100% rename from router/authentication/jwks.go rename to router/pkg/authentication/jwks.go diff --git a/router/config/config.go b/router/pkg/config/config.go similarity index 99% rename from router/config/config.go rename to router/pkg/config/config.go index deb17ef39b..3d46d7ec50 100644 --- a/router/config/config.go +++ b/router/pkg/config/config.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "github.com/wundergraph/cosmo/router/pkg/logging" + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" "os" "time" @@ -10,12 +12,11 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "go.uber.org/zap" - - "github.com/wundergraph/cosmo/router/internal/logging" - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" ) -const defaultConfigPath = "config.yaml" +const ( + defaultConfigPath = "config.yaml" +) type Graph struct { // Name is required if no router config path is provided diff --git a/router/config/loadvariable.go b/router/pkg/config/loadvariable.go similarity index 100% rename from router/config/loadvariable.go rename to router/pkg/config/loadvariable.go diff --git a/router/config/marshaler.go b/router/pkg/config/marshaler.go similarity index 100% rename from router/config/marshaler.go rename to router/pkg/config/marshaler.go diff --git a/router/internal/handler/cors/config.go b/router/pkg/cors/config.go similarity index 100% rename from router/internal/handler/cors/config.go rename to router/pkg/cors/config.go diff --git a/router/internal/handler/cors/cors.go b/router/pkg/cors/cors.go similarity index 100% rename from router/internal/handler/cors/cors.go rename to router/pkg/cors/cors.go diff --git a/router/internal/handler/cors/cors_test.go b/router/pkg/cors/cors_test.go similarity index 100% rename from router/internal/handler/cors/cors_test.go rename to router/pkg/cors/cors_test.go diff --git a/router/internal/handler/cors/utils.go b/router/pkg/cors/utils.go similarity index 100% rename from router/internal/handler/cors/utils.go rename to router/pkg/cors/utils.go diff --git a/router/health/health.go b/router/pkg/health/health.go similarity index 100% rename from router/health/health.go rename to router/pkg/health/health.go diff --git a/router/health/health_test.go b/router/pkg/health/health_test.go similarity index 100% rename from router/health/health_test.go rename to router/pkg/health/health_test.go diff --git a/router/internal/logging/logging.go b/router/pkg/logging/logging.go similarity index 100% rename from router/internal/logging/logging.go rename to router/pkg/logging/logging.go diff --git a/router/internal/metric/config.go b/router/pkg/metric/config.go similarity index 95% rename from router/internal/metric/config.go rename to router/pkg/metric/config.go index 34718d442d..7d88e89701 100644 --- a/router/internal/metric/config.go +++ b/router/pkg/metric/config.go @@ -1,7 +1,7 @@ package metric import ( - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" "net/url" "regexp" ) @@ -87,7 +87,7 @@ func DefaultConfig(serviceVersion string) *Config { Disabled: false, Endpoint: "http://localhost:4318", Exporter: otelconfig.ExporterOLTPHTTP, - HTTPPath: "/v1/metrics", + HTTPPath: otelconfig.DefaultMetricsPath, }, }, }, diff --git a/router/internal/metric/meter.go b/router/pkg/metric/meter.go similarity index 99% rename from router/internal/metric/meter.go rename to router/pkg/metric/meter.go index e1383ba2e7..4d42776058 100644 --- a/router/internal/metric/meter.go +++ b/router/pkg/metric/meter.go @@ -5,13 +5,13 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" "go.opentelemetry.io/otel/attribute" otelprom "go.opentelemetry.io/otel/exporters/prometheus" "net/url" "regexp" "time" - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" diff --git a/router/internal/metric/metrics.go b/router/pkg/metric/metrics.go similarity index 98% rename from router/internal/metric/metrics.go rename to router/pkg/metric/metrics.go index a3143458c4..7b507f3e6f 100644 --- a/router/internal/metric/metrics.go +++ b/router/pkg/metric/metrics.go @@ -82,6 +82,7 @@ type ( MeasureRequestSize(ctx context.Context, contentLength int64, attr ...attribute.KeyValue) MeasureResponseSize(ctx context.Context, size int64, attr ...attribute.KeyValue) MeasureLatency(ctx context.Context, requestStartTime time.Time, attr ...attribute.KeyValue) + ForceFlush(ctx context.Context) error } ) @@ -336,6 +337,10 @@ func (h *Metrics) MeasureLatency(ctx context.Context, requestStartTime time.Time } } +func (h *Metrics) ForceFlush(ctx context.Context) error { + return h.otelMeterProvider.ForceFlush(ctx) +} + // WithAttributes adds attributes to the base attributes func WithAttributes(attrs ...attribute.KeyValue) Option { return func(h *Metrics) { diff --git a/router/internal/metric/noop_metrics.go b/router/pkg/metric/noop_metrics.go similarity index 91% rename from router/internal/metric/noop_metrics.go rename to router/pkg/metric/noop_metrics.go index 3dbdcd2807..a2a67e1195 100644 --- a/router/internal/metric/noop_metrics.go +++ b/router/pkg/metric/noop_metrics.go @@ -29,3 +29,7 @@ func (n NoopMetrics) MeasureResponseSize(ctx context.Context, size int64, attr . func (n NoopMetrics) MeasureLatency(ctx context.Context, requestStartTime time.Time, attr ...attribute.KeyValue) { } + +func (n NoopMetrics) ForceFlush(ctx context.Context) error { + return nil +} diff --git a/router/internal/metric/prometheus.go b/router/pkg/metric/prometheus.go similarity index 96% rename from router/internal/metric/prometheus.go rename to router/pkg/metric/prometheus.go index 3394a79119..4992d6e39c 100644 --- a/router/internal/metric/prometheus.go +++ b/router/pkg/metric/prometheus.go @@ -5,7 +5,7 @@ import ( "github.com/go-chi/chi/middleware" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/wundergraph/cosmo/router/internal/otel" + "github.com/wundergraph/cosmo/router/pkg/otel" "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" "net/http" diff --git a/router/internal/otel/attributes.go b/router/pkg/otel/attributes.go similarity index 100% rename from router/internal/otel/attributes.go rename to router/pkg/otel/attributes.go diff --git a/router/internal/otel/otelconfig/otelconfig.go b/router/pkg/otel/otelconfig/otelconfig.go similarity index 77% rename from router/internal/otel/otelconfig/otelconfig.go rename to router/pkg/otel/otelconfig/otelconfig.go index d990ba1ae9..3a60805400 100644 --- a/router/internal/otel/otelconfig/otelconfig.go +++ b/router/pkg/otel/otelconfig/otelconfig.go @@ -8,6 +8,10 @@ const ( ExporterDefault Exporter = "" // Use ExporterOLTPHTTP ExporterOLTPHTTP Exporter = "http" ExporterOLTPGRPC Exporter = "grpc" + + CloudDefaultTelemetryEndpoint = "https://cosmo-otel.wundergraph.com" + DefaultMetricsPath = "/v1/metrics" + DefaultTracesPath = "/v1/traces" ) // DefaultEndpoint is the default endpoint used by subsystems that @@ -17,7 +21,7 @@ func DefaultEndpoint() string { if ep := os.Getenv("DEFAULT_TELEMETRY_ENDPOINT"); ep != "" { return ep } - return "https://cosmo-otel.wundergraph.com" + return CloudDefaultTelemetryEndpoint } // DefaultEndpointHeaders returns the headers required to talk to the default diff --git a/router/internal/trace/config.go b/router/pkg/trace/config.go similarity index 87% rename from router/internal/trace/config.go rename to router/pkg/trace/config.go index 865952ca88..9610558082 100644 --- a/router/internal/trace/config.go +++ b/router/pkg/trace/config.go @@ -1,10 +1,9 @@ package trace import ( + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" "net/url" "time" - - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" ) // ServerName Default resource name. @@ -17,6 +16,9 @@ const ( PropagatorB3 Propagator = "b3" PropagatorJaeger Propagator = "jaeger" PropagatorBaggage Propagator = "baggage" + + DefaultBatchTimeout = 10 * time.Second + DefaultExportTimeout = 30 * time.Second ) type Exporter struct { @@ -81,9 +83,9 @@ func DefaultConfig(serviceVersion string) *Config { Disabled: false, Endpoint: "http://localhost:4318", Exporter: otelconfig.ExporterOLTPHTTP, - HTTPPath: "/v1/traces", - BatchTimeout: defaultBatchTimeout, - ExportTimeout: defaultExportTimeout, + HTTPPath: otelconfig.DefaultTracesPath, + BatchTimeout: DefaultBatchTimeout, + ExportTimeout: DefaultExportTimeout, }, }, } diff --git a/router/internal/trace/meter.go b/router/pkg/trace/meter.go similarity index 95% rename from router/internal/trace/meter.go rename to router/pkg/trace/meter.go index 04429a2c45..a9f99a36bf 100644 --- a/router/internal/trace/meter.go +++ b/router/pkg/trace/meter.go @@ -3,10 +3,7 @@ package trace import ( "context" "fmt" - "net/url" - "time" - - "github.com/wundergraph/cosmo/router/internal/otel/otelconfig" + "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" @@ -14,15 +11,11 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.4.0" "go.uber.org/zap" + "net/url" _ "google.golang.org/grpc/encoding/gzip" // Required for gzip support over grpc ) -const ( - defaultBatchTimeout = 10 * time.Second - defaultExportTimeout = 30 * time.Second -) - var ( tp *sdktrace.TracerProvider ) @@ -146,12 +139,12 @@ func NewTracerProvider(ctx context.Context, log *zap.Logger, c *Config) (*sdktra batchTimeout := exp.BatchTimeout if batchTimeout == 0 { - batchTimeout = defaultBatchTimeout + batchTimeout = DefaultBatchTimeout } exportTimeout := exp.ExportTimeout if exportTimeout == 0 { - exportTimeout = defaultExportTimeout + exportTimeout = DefaultExportTimeout } // Always be sure to batch in production. diff --git a/router/internal/trace/meter_test.go b/router/pkg/trace/meter_test.go similarity index 100% rename from router/internal/trace/meter_test.go rename to router/pkg/trace/meter_test.go diff --git a/router/internal/trace/middleware.go b/router/pkg/trace/middleware.go similarity index 100% rename from router/internal/trace/middleware.go rename to router/pkg/trace/middleware.go diff --git a/router/internal/trace/middleware_test.go b/router/pkg/trace/middleware_test.go similarity index 96% rename from router/internal/trace/middleware_test.go rename to router/pkg/trace/middleware_test.go index 1bfc0285d7..aed062483c 100644 --- a/router/internal/trace/middleware_test.go +++ b/router/pkg/trace/middleware_test.go @@ -2,7 +2,8 @@ package trace import ( "github.com/go-chi/chi" - "github.com/wundergraph/cosmo/router/internal/otel" + "github.com/wundergraph/cosmo/router/pkg/otel" + "github.com/wundergraph/cosmo/router/pkg/trace/tracetest" "net/http" "net/http/httptest" "testing" @@ -13,8 +14,6 @@ import ( semconv12 "go.opentelemetry.io/otel/semconv/v1.12.0" semconv17 "go.opentelemetry.io/otel/semconv/v1.17.0" "go.opentelemetry.io/otel/trace" - - "github.com/wundergraph/cosmo/router/internal/trace/tracetest" ) func TestWrapHttpHandler(t *testing.T) { diff --git a/router/internal/trace/propagation.go b/router/pkg/trace/propagation.go similarity index 100% rename from router/internal/trace/propagation.go rename to router/pkg/trace/propagation.go diff --git a/router/internal/trace/tracer.go b/router/pkg/trace/tracer.go similarity index 100% rename from router/internal/trace/tracer.go rename to router/pkg/trace/tracer.go diff --git a/router/internal/trace/tracer_test.go b/router/pkg/trace/tracer_test.go similarity index 100% rename from router/internal/trace/tracer_test.go rename to router/pkg/trace/tracer_test.go diff --git a/router/internal/trace/tracetest/tracetest.go b/router/pkg/trace/tracetest/tracetest.go similarity index 100% rename from router/internal/trace/tracetest/tracetest.go rename to router/pkg/trace/tracetest/tracetest.go diff --git a/router/internal/trace/transport.go b/router/pkg/trace/transport.go similarity index 100% rename from router/internal/trace/transport.go rename to router/pkg/trace/transport.go diff --git a/router/internal/trace/transport_test.go b/router/pkg/trace/transport_test.go similarity index 96% rename from router/internal/trace/transport_test.go rename to router/pkg/trace/transport_test.go index 1318b2def3..4f0e404903 100644 --- a/router/internal/trace/transport_test.go +++ b/router/pkg/trace/transport_test.go @@ -3,7 +3,8 @@ package trace import ( "bytes" "context" - "github.com/wundergraph/cosmo/router/internal/otel" + "github.com/wundergraph/cosmo/router/pkg/otel" + "github.com/wundergraph/cosmo/router/pkg/trace/tracetest" "io" "net/http" "net/http/httptest" @@ -15,8 +16,6 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.17.0" "go.opentelemetry.io/otel/trace" - - "github.com/wundergraph/cosmo/router/internal/trace/tracetest" ) func TestTransport(t *testing.T) { diff --git a/router/internal/trace/utils.go b/router/pkg/trace/utils.go similarity index 100% rename from router/internal/trace/utils.go rename to router/pkg/trace/utils.go diff --git a/router/internal/trace/utils_test.go b/router/pkg/trace/utils_test.go similarity index 100% rename from router/internal/trace/utils_test.go rename to router/pkg/trace/utils_test.go diff --git a/router/internal/trace/vars.go b/router/pkg/trace/vars.go similarity index 100% rename from router/internal/trace/vars.go rename to router/pkg/trace/vars.go