From acf3381c8fd97adf9be192459401b4e1d1e6a854 Mon Sep 17 00:00:00 2001 From: jace-ys Date: Tue, 6 Aug 2024 22:58:24 +0100 Subject: [PATCH] Add README --- .github/dependabot.yml | 15 + .github/workflows/app.yaml | 96 + .github/workflows/digger_workflow.yml | 42 + .gitignore | 39 + .mise.toml | 12 + README.md | 12 + Taskfile.yaml | 135 + app/.golangci.yaml | 127 + app/Dockerfile | 15 + app/api/v1/countup.go | 130 + app/api/v1/gen/api/client.go | 72 + app/api/v1/gen/api/endpoints.go | 73 + app/api/v1/gen/api/service.go | 119 + app/api/v1/gen/api/views/view.go | 71 + app/api/v1/gen/grpc/api/client/cli.go | 55 + app/api/v1/gen/grpc/api/client/client.go | 88 + .../v1/gen/grpc/api/client/encode_decode.go | 127 + app/api/v1/gen/grpc/api/client/types.go | 72 + .../v1/gen/grpc/api/pb/goagen_v1_api.pb.go | 448 ++ .../v1/gen/grpc/api/pb/goagen_v1_api.proto | 51 + .../gen/grpc/api/pb/goagen_v1_api_grpc.pb.go | 214 + .../v1/gen/grpc/api/server/encode_decode.go | 91 + app/api/v1/gen/grpc/api/server/server.go | 124 + app/api/v1/gen/grpc/api/server/types.go | 65 + app/api/v1/gen/grpc/cli/countup/cli.go | 186 + app/api/v1/gen/http/api/client/cli.go | 50 + app/api/v1/gen/http/api/client/client.go | 127 + .../v1/gen/http/api/client/encode_decode.go | 317 ++ app/api/v1/gen/http/api/client/paths.go | 23 + app/api/v1/gen/http/api/client/types.go | 457 ++ .../v1/gen/http/api/server/encode_decode.go | 240 + app/api/v1/gen/http/api/server/paths.go | 23 + app/api/v1/gen/http/api/server/server.go | 272 ++ app/api/v1/gen/http/api/server/types.go | 315 ++ app/api/v1/gen/http/cli/countup/cli.go | 262 + app/api/v1/gen/http/openapi.json | 1 + app/api/v1/gen/http/openapi.yaml | 527 ++ app/api/v1/gen/http/openapi3.json | 1 + app/api/v1/gen/http/openapi3.yaml | 383 ++ app/api/v1/gen/http/web/client/cli.go | 8 + app/api/v1/gen/http/web/client/client.go | 93 + .../v1/gen/http/web/client/encode_decode.go | 118 + app/api/v1/gen/http/web/client/paths.go | 18 + app/api/v1/gen/http/web/client/types.go | 8 + .../v1/gen/http/web/server/encode_decode.go | 41 + app/api/v1/gen/http/web/server/paths.go | 18 + app/api/v1/gen/http/web/server/server.go | 207 + app/api/v1/gen/http/web/server/types.go | 8 + app/api/v1/gen/web/client.go | 48 + app/api/v1/gen/web/endpoints.go | 50 + app/api/v1/gen/web/service.go | 36 + app/atlas.hcl | 9 + app/cmd/countup-cli/grpc.go | 19 + app/cmd/countup-cli/http.go | 39 + app/cmd/countup-cli/main.go | 124 + app/cmd/countup/globals.go | 10 + app/cmd/countup/main.go | 46 + app/cmd/countup/migrate.go | 63 + app/cmd/countup/server.go | 120 + app/cmd/countup/version.go | 20 + app/go.mod | 75 + app/go.sum | 193 + app/internal/app/admin.go | 83 + app/internal/app/application.go | 69 + app/internal/app/grpc.go | 131 + app/internal/app/http.go | 120 + app/internal/endpoints/goa.go | 45 + .../endpoints/middleware/goaerror/reporter.go | 36 + .../endpoints/middleware/tracer/endpoint.go | 31 + app/internal/handler/api/echo.go | 72 + app/internal/handler/api/handler.go | 46 + app/internal/handler/api/increment.go | 51 + app/internal/handler/web/handler.go | 69 + app/internal/handler/web/pages.go | 25 + .../handler/web/static/assets/gopher.png | Bin 0 -> 190838 bytes app/internal/handler/web/static/css/main.css | 45 + app/internal/handler/web/static/js/main.js | 28 + app/internal/handler/web/static/quote.html | 33 + .../handler/web/templates/another.html | 15 + app/internal/handler/web/templates/index.html | 19 + app/internal/healthz/checker.go | 24 + app/internal/healthz/grpc.go | 39 + app/internal/healthz/handler.go | 33 + app/internal/healthz/http.go | 34 + app/internal/instrument/otel.go | 123 + app/internal/instrument/panic.go | 53 + app/internal/postgres/pool.go | 41 + app/internal/service/counter/errors.go | 30 + app/internal/service/counter/finalize.go | 99 + app/internal/service/counter/service.go | 131 + .../service/counter/store/counter.sql.go | 136 + app/internal/service/counter/store/db.go | 25 + app/internal/service/counter/store/models.go | 22 + app/internal/service/counter/store/querier.go | 23 + app/internal/service/counter/types.go | 24 + app/internal/slog/clue.go | 61 + app/internal/slog/middleware.go | 47 + app/internal/slog/stdslog.go | 105 + app/internal/slog/stdslog_test.go | 184 + app/internal/transport/goa.go | 99 + .../transport/middleware/idgen/request.go | 56 + .../transport/middleware/recovery/recovery.go | 53 + .../transport/middleware/telemetry/http.go | 25 + app/internal/versioninfo/version.go | 6 + app/internal/worker/instrumented.go | 159 + app/internal/worker/metadata.go | 31 + app/internal/worker/metrics.go | 124 + app/internal/worker/options.go | 47 + app/internal/worker/pool.go | 133 + app/schema/counter.sql | 46 + .../20241003194615_rivermigrate002.go | 27 + .../20241003194729_rivermigrate003.go | 27 + .../20241003195032_rivermigrate004.go | 27 + .../20241003195147_rivermigrate005.go | 27 + .../20241003195218_rivermigrate006.go | 27 + .../20241005193820_create_table_counter.sql | 7 + .../20241005201313_init_table_counter.sql | 5 + ...142909_create_table_increment_requests.sql | 7 + ...increment_requests_requested_by_unique.sql | 7 + app/schema/migrations/atlas.sum | 5 + app/schema/migrations/embed.go | 8 + app/schema/migrations/rivermigrate.go | 22 + app/schema/schema.sql | 12 + app/schema/users.sql | 0 app/sqlc.yaml | 15 + compose.yaml | 202 + digger.yml | 8 + .../dev/compose/grafana/config.ini | 14 + .../grafana/definitions/otel-collector.json | 4006 +++++++++++++++ .../compose/grafana/definitions/service.json | 4322 +++++++++++++++++ .../provisioning/dashboards/dashboards.yaml | 12 + .../provisioning/datasources/datasources.yaml | 70 + .../plugins/loki-explorer-app.yaml | 7 + .../environments/dev/compose/loki/config.yaml | 33 + .../dev/compose/mimir/config.yaml | 41 + .../dev/compose/otel-collector/config.yaml | 211 + .../dev/compose/postgres/init/river.sql | 271 ++ .../dev/compose/promtail/config.yaml | 151 + .../dev/compose/tempo/config.yaml | 88 + infra/environments/dev/gcp/main.tf | 11 + infra/environments/dev/gcp/terraform.tf | 17 + infra/environments/dev/gcp/variables.tf | 9 + infra/environments/prd/.gitkeep | 0 infra/spacelift/init/.terraform.lock.hcl | 36 + infra/spacelift/init/contexts.tf | 34 + infra/spacelift/init/spacelift.tf | 59 + infra/spacelift/init/terraform.tf | 27 + infra/spacelift/init/variables.tf | 24 + 148 files changed, 19655 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/app.yaml create mode 100644 .github/workflows/digger_workflow.yml create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 README.md create mode 100644 Taskfile.yaml create mode 100644 app/.golangci.yaml create mode 100644 app/Dockerfile create mode 100644 app/api/v1/countup.go create mode 100644 app/api/v1/gen/api/client.go create mode 100644 app/api/v1/gen/api/endpoints.go create mode 100644 app/api/v1/gen/api/service.go create mode 100644 app/api/v1/gen/api/views/view.go create mode 100644 app/api/v1/gen/grpc/api/client/cli.go create mode 100644 app/api/v1/gen/grpc/api/client/client.go create mode 100644 app/api/v1/gen/grpc/api/client/encode_decode.go create mode 100644 app/api/v1/gen/grpc/api/client/types.go create mode 100644 app/api/v1/gen/grpc/api/pb/goagen_v1_api.pb.go create mode 100644 app/api/v1/gen/grpc/api/pb/goagen_v1_api.proto create mode 100644 app/api/v1/gen/grpc/api/pb/goagen_v1_api_grpc.pb.go create mode 100644 app/api/v1/gen/grpc/api/server/encode_decode.go create mode 100644 app/api/v1/gen/grpc/api/server/server.go create mode 100644 app/api/v1/gen/grpc/api/server/types.go create mode 100644 app/api/v1/gen/grpc/cli/countup/cli.go create mode 100644 app/api/v1/gen/http/api/client/cli.go create mode 100644 app/api/v1/gen/http/api/client/client.go create mode 100644 app/api/v1/gen/http/api/client/encode_decode.go create mode 100644 app/api/v1/gen/http/api/client/paths.go create mode 100644 app/api/v1/gen/http/api/client/types.go create mode 100644 app/api/v1/gen/http/api/server/encode_decode.go create mode 100644 app/api/v1/gen/http/api/server/paths.go create mode 100644 app/api/v1/gen/http/api/server/server.go create mode 100644 app/api/v1/gen/http/api/server/types.go create mode 100644 app/api/v1/gen/http/cli/countup/cli.go create mode 100644 app/api/v1/gen/http/openapi.json create mode 100644 app/api/v1/gen/http/openapi.yaml create mode 100644 app/api/v1/gen/http/openapi3.json create mode 100644 app/api/v1/gen/http/openapi3.yaml create mode 100644 app/api/v1/gen/http/web/client/cli.go create mode 100644 app/api/v1/gen/http/web/client/client.go create mode 100644 app/api/v1/gen/http/web/client/encode_decode.go create mode 100644 app/api/v1/gen/http/web/client/paths.go create mode 100644 app/api/v1/gen/http/web/client/types.go create mode 100644 app/api/v1/gen/http/web/server/encode_decode.go create mode 100644 app/api/v1/gen/http/web/server/paths.go create mode 100644 app/api/v1/gen/http/web/server/server.go create mode 100644 app/api/v1/gen/http/web/server/types.go create mode 100644 app/api/v1/gen/web/client.go create mode 100644 app/api/v1/gen/web/endpoints.go create mode 100644 app/api/v1/gen/web/service.go create mode 100644 app/atlas.hcl create mode 100644 app/cmd/countup-cli/grpc.go create mode 100644 app/cmd/countup-cli/http.go create mode 100644 app/cmd/countup-cli/main.go create mode 100644 app/cmd/countup/globals.go create mode 100644 app/cmd/countup/main.go create mode 100644 app/cmd/countup/migrate.go create mode 100644 app/cmd/countup/server.go create mode 100644 app/cmd/countup/version.go create mode 100644 app/go.mod create mode 100644 app/go.sum create mode 100644 app/internal/app/admin.go create mode 100644 app/internal/app/application.go create mode 100644 app/internal/app/grpc.go create mode 100644 app/internal/app/http.go create mode 100644 app/internal/endpoints/goa.go create mode 100644 app/internal/endpoints/middleware/goaerror/reporter.go create mode 100644 app/internal/endpoints/middleware/tracer/endpoint.go create mode 100644 app/internal/handler/api/echo.go create mode 100644 app/internal/handler/api/handler.go create mode 100644 app/internal/handler/api/increment.go create mode 100644 app/internal/handler/web/handler.go create mode 100644 app/internal/handler/web/pages.go create mode 100644 app/internal/handler/web/static/assets/gopher.png create mode 100644 app/internal/handler/web/static/css/main.css create mode 100644 app/internal/handler/web/static/js/main.js create mode 100644 app/internal/handler/web/static/quote.html create mode 100644 app/internal/handler/web/templates/another.html create mode 100644 app/internal/handler/web/templates/index.html create mode 100644 app/internal/healthz/checker.go create mode 100644 app/internal/healthz/grpc.go create mode 100644 app/internal/healthz/handler.go create mode 100644 app/internal/healthz/http.go create mode 100644 app/internal/instrument/otel.go create mode 100644 app/internal/instrument/panic.go create mode 100644 app/internal/postgres/pool.go create mode 100644 app/internal/service/counter/errors.go create mode 100644 app/internal/service/counter/finalize.go create mode 100644 app/internal/service/counter/service.go create mode 100644 app/internal/service/counter/store/counter.sql.go create mode 100644 app/internal/service/counter/store/db.go create mode 100644 app/internal/service/counter/store/models.go create mode 100644 app/internal/service/counter/store/querier.go create mode 100644 app/internal/service/counter/types.go create mode 100644 app/internal/slog/clue.go create mode 100644 app/internal/slog/middleware.go create mode 100644 app/internal/slog/stdslog.go create mode 100644 app/internal/slog/stdslog_test.go create mode 100644 app/internal/transport/goa.go create mode 100644 app/internal/transport/middleware/idgen/request.go create mode 100644 app/internal/transport/middleware/recovery/recovery.go create mode 100644 app/internal/transport/middleware/telemetry/http.go create mode 100644 app/internal/versioninfo/version.go create mode 100644 app/internal/worker/instrumented.go create mode 100644 app/internal/worker/metadata.go create mode 100644 app/internal/worker/metrics.go create mode 100644 app/internal/worker/options.go create mode 100644 app/internal/worker/pool.go create mode 100644 app/schema/counter.sql create mode 100644 app/schema/migrations/20241003194615_rivermigrate002.go create mode 100644 app/schema/migrations/20241003194729_rivermigrate003.go create mode 100644 app/schema/migrations/20241003195032_rivermigrate004.go create mode 100644 app/schema/migrations/20241003195147_rivermigrate005.go create mode 100644 app/schema/migrations/20241003195218_rivermigrate006.go create mode 100644 app/schema/migrations/20241005193820_create_table_counter.sql create mode 100644 app/schema/migrations/20241005201313_init_table_counter.sql create mode 100644 app/schema/migrations/20241006142909_create_table_increment_requests.sql create mode 100644 app/schema/migrations/20241008214418_increment_requests_requested_by_unique.sql create mode 100644 app/schema/migrations/atlas.sum create mode 100644 app/schema/migrations/embed.go create mode 100644 app/schema/migrations/rivermigrate.go create mode 100644 app/schema/schema.sql create mode 100644 app/schema/users.sql create mode 100644 app/sqlc.yaml create mode 100644 compose.yaml create mode 100644 digger.yml create mode 100644 infra/environments/dev/compose/grafana/config.ini create mode 100644 infra/environments/dev/compose/grafana/definitions/otel-collector.json create mode 100644 infra/environments/dev/compose/grafana/definitions/service.json create mode 100644 infra/environments/dev/compose/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 infra/environments/dev/compose/grafana/provisioning/datasources/datasources.yaml create mode 100644 infra/environments/dev/compose/grafana/provisioning/plugins/loki-explorer-app.yaml create mode 100644 infra/environments/dev/compose/loki/config.yaml create mode 100644 infra/environments/dev/compose/mimir/config.yaml create mode 100644 infra/environments/dev/compose/otel-collector/config.yaml create mode 100644 infra/environments/dev/compose/postgres/init/river.sql create mode 100644 infra/environments/dev/compose/promtail/config.yaml create mode 100644 infra/environments/dev/compose/tempo/config.yaml create mode 100644 infra/environments/dev/gcp/main.tf create mode 100644 infra/environments/dev/gcp/terraform.tf create mode 100644 infra/environments/dev/gcp/variables.tf create mode 100644 infra/environments/prd/.gitkeep create mode 100644 infra/spacelift/init/.terraform.lock.hcl create mode 100644 infra/spacelift/init/contexts.tf create mode 100644 infra/spacelift/init/spacelift.tf create mode 100644 infra/spacelift/init/terraform.tf create mode 100644 infra/spacelift/init/variables.tf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6ed914b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +--- +version: 2 +updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" +- package-ecosystem: "gomod" + directory: "/app" + schedule: + interval: "weekly" +- package-ecosystem: "docker" + directory: "/app" + schedule: + interval: "weekly" diff --git a/.github/workflows/app.yaml b/.github/workflows/app.yaml new file mode 100644 index 0000000..439cb0b --- /dev/null +++ b/.github/workflows/app.yaml @@ -0,0 +1,96 @@ +--- +name: app + +on: [push] + +defaults: + run: + working-directory: ./app + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + with: + version: 2024.10.7 + install: true + experimental: true + - uses: actions/setup-go@v5 + with: + go-version: '1.23.2' + cache-dependency-path: 'app/go.sum' + - run: go mod tidy + - run: task gen + - name: Save task cache + uses: actions/cache/save@v4 + with: + path: ./.task + key: task-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.task/checksum/*') }} + - name: Check git status + run: if [[ -n $(git status --porcelain) ]]; then git status --porcelain && exit 1; fi + + lint: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Restore task cache + uses: actions/cache/restore@v4 + with: + path: ./.task + key: task-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.task/checksum/*') }} + - uses: jdx/mise-action@v2 + with: + version: 2024.10.7 + install: false + - uses: actions/setup-go@v5 + with: + go-version: '1.23.2' + cache-dependency-path: 'app/go.sum' + - uses: golangci/golangci-lint-action@v6 + with: + version: v1.61.0 + working-directory: ./app + args: --path-prefix=./ + + test: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Restore task cache + uses: actions/cache/restore@v4 + with: + path: ./.task + key: task-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.task/checksum/*') }} + - uses: jdx/mise-action@v2 + with: + version: 2024.10.7 + install: false + - uses: actions/setup-go@v5 + with: + go-version: '1.23.2' + cache-dependency-path: 'app/go.sum' + - run: task test + + build: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Restore task cache + uses: actions/cache/restore@v4 + with: + path: ./.task + key: task-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.task/checksum/*') }} + - uses: jdx/mise-action@v2 + with: + version: 2024.10.7 + install: false + - uses: actions/setup-go@v5 + with: + go-version: '1.23.2' + cache-dependency-path: 'app/go.sum' + - run: task build diff --git a/.github/workflows/digger_workflow.yml b/.github/workflows/digger_workflow.yml new file mode 100644 index 0000000..44d3dff --- /dev/null +++ b/.github/workflows/digger_workflow.yml @@ -0,0 +1,42 @@ +--- +name: digger + +on: + workflow_dispatch: + inputs: + spec: + required: true + description: Input for digger-spec + run_name: + required: false + description: Name for the workflow run + +run-name: ${{ inputs.run_name }} + +jobs: + digger-job: + name: Digger + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + id-token: write + pull-requests: write + issues: read + statuses: write + steps: + - uses: actions/checkout@v4 + - name: digger run + uses: diggerhq/digger@vLatest + with: + digger-spec: ${{ inputs.spec }} + setup-terraform: true + terraform-version: v1.9.8 + cache-dependencies: true + setup-google-cloud: true + google-service-account: digger@emp-jace-a850.iam.gserviceaccount.com + google-workload-identity-provider: projects/639381616334/locations/global/workloadIdentityPools/digger/providers/digger + reporting-strategy: latest_run_comment + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfed2be --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Misc +.DS_Store + +# Environment +.env +.env.local +.env.*.local + +# Editor +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Terraform +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfvars +.terraform.tfstate.lock.info + +# Dist +dist/ + +# Task +.task/ \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..a224db0 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,12 @@ +[tools] +atlas = "0.28.0" +golang = "1.23.2" +golangci-lint = "1.61.0" +protoc = "28.2" +protoc-gen-go = "1.35.1" +protoc-gen-go-grpc = "1.5.1" +task = "3.39.2" +terraform = "1.5.7" + +"go:github.com/sqlc-dev/sqlc/cmd/sqlc" = "1.27.0" +"go:goa.design/goa/v3/cmd/goa" = "3.19.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a85f711 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Count Up + +A little web app game, consolidating the best practices from my years of experience building production-ready software and infrastructure. + +Probably way overengineered for incrementing a counter, but thought this will be a good exercise to codify my learnings! + +## Features + +- Async, transactional worker system for processing background jobs using [River](https://riverqueue.com/). +- Compiled SQL queries into type-safe application code using [sqlc](https://sqlc.dev/). +- Declarative database schema and migrations using a combination of [Atlas](https://atlasgo.io/) + [Goose](https://pressly.github.io/goose/). +- DDD-lite, interface-driven approach to writing decoupled and testable business logic. \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..b11cfef --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,135 @@ +--- +version: '3' + +vars: + VERSION: + sh: git describe --tags --exact-match HEAD 2>/dev/null || echo "0.0.0" + GIT_COMMIT: + sh: git rev-parse --short HEAD + LDFLAGS: + - '-X github.com/jace-ys/countup/internal/versioninfo.Version={{ .VERSION }}' + - '-X github.com/jace-ys/countup/internal/versioninfo.CommitSHA={{ .GIT_COMMIT }}' + +tasks: + run:server: + deps: ['gen'] + dir: app + cmds: + - go run ./cmd/countup/... server {{ .CLI_ARGS }} + env: + DEBUG: true + OTEL_GO_X_EXEMPLAR: true + OTEL_RESOURCE_ATTRIBUTES: tier=app,environment=dev + DATABASE_CONNECTION_URI: postgresql://countup:countup@localhost:5432/countup + + run:client: + deps: ['gen'] + dir: app + cmds: + - go run ./cmd/countup-cli/... {{ .CLI_ARGS }} + + test: + deps: ['gen'] + dir: app + cmds: + - go test -race ./... + + lint: + deps: ['gen'] + dir: app + cmds: + - golangci-lint run ./... + + gen: + dir: app + cmds: + - task: gen:api:v1 + - task: gen:sqlc + + gen:api:*: + internal: true + dir: app + vars: + API_VERSION: '{{ index .MATCH 0 }}' + cmds: + - goa gen github.com/jace-ys/countup/api/{{ .API_VERSION }} -o api/{{ .API_VERSION }} + sources: + - api/{{ .API_VERSION }}/*.go + generates: + - api/{{ .API_VERSION }}/gen/**/* + + gen:sqlc: + internal: true + deps: ['migration:plan'] + dir: app + cmds: + - sqlc generate + sources: + - schema/*.sql + + migration:new: + dir: app + cmds: + - atlas migrate new --env dev {{ .NAME }} + requires: + vars: [NAME] + + migration:plan: + dir: app + cmds: + - atlas migrate diff --env dev {{ .NAME }} + sources: + - schema/schema.sql + - schema/migrations/*.sql + generates: + - schema/migrations/*.sql + + migration:hash: + dir: app + cmds: + - atlas migrate hash --env dev + + build: + deps: ['gen'] + dir: app + cmds: + - task: build:server + - task: build:client + - task: build:image + + build:server: + internal: true + dir: app + cmds: + - go build -ldflags='{{ .LDFLAGS | join " " }}' -o ./dist/ ./cmd/countup/... + + build:client: + internal: true + dir: app + cmds: + - go build -ldflags='{{ .LDFLAGS | join " " }}' -o ./dist/ ./cmd/countup-cli/... + + build:image: + internal: true + dir: app + cmds: + - docker build --build-arg LDFLAGS='{{ .LDFLAGS | join " " }}' -t jace-ys/countup:{{ .VERSION }} . + + compose: + ignore_error: true + deps: ['gen'] + cmds: + - docker compose --profile apps up --build {{ .CLI_ARGS }} + - defer: {task: 'compose:down'} + + compose:infra: + ignore_error: true + deps: ['gen'] + cmds: + - docker compose up {{ .CLI_ARGS }} + - defer: {task: 'compose:down'} + + compose:down: + ignore_error: true + cmds: + - docker compose down -v --remove-orphans diff --git a/app/.golangci.yaml b/app/.golangci.yaml new file mode 100644 index 0000000..4bf917c --- /dev/null +++ b/app/.golangci.yaml @@ -0,0 +1,127 @@ +--- +linters-settings: + cyclop: + package-average: 10.0 + + errcheck: + check-type-assertions: true + check-blank: true + exclude-functions: + - (github.com/jackc/pgx/v5.Tx).Rollback + + exhaustive: + check: + - switch + - map + + funlen: + lines: 100 + statements: 50 + ignore-comments: true + + gci: + sections: + - standard + - default + - localmodule + + gocognit: + min-complexity: 20 + + gocritic: + disabled-checks: + - singleCaseSwitch + + govet: + enable-all: true + disable: + - fieldalignment + - shadow + + nakedret: + max-func-lines: 0 + + perfsprint: + strconcat: false + +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + + - asasalint + - asciicheck + - bidichk + - bodyclose + - canonicalheader + - copyloopvar + - cyclop + - decorder + - dupl + - durationcheck + - errname + - errorlint + - exhaustive + - fatcontext + - funlen + - gci + - ginkgolinter + - gocheckcompilerdirectives + - gochecksumtype + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - goimports + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - intrange + - loggercheck + - makezero + - mirror + - musttag + - nakedret + - nestif + - nilerr + - nilnil + - noctx + - nolintlint + - nosprintfhostport + - perfsprint + - prealloc + - predeclared + - promlinter + - protogetter + - reassign + - spancheck + - sqlclosecheck + - tenv + - testableexamples + - testifylint + - testpackage + - tparallel + - unconvert + - usestdlibvars + - wastedassign + - whitespace + - wrapcheck + +issues: + max-same-issues: 50 + + exclude-dirs: + - ^api$ + - ^cmd/countup-cli$ + - ^schema/migrations$ + + exclude-files: + - ^internal/handler/api/echo.go$ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..44de05a --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.23.2 AS builder + +ARG LDFLAGS +ENV LDFLAGS=${LDFLAGS} + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . ./ +RUN CGO_ENABLED=0 go build -ldflags="${LDFLAGS}" -o dist/ ./cmd/countup/... + +FROM scratch +WORKDIR /app +COPY --from=builder /src/dist /app/bin +ENTRYPOINT ["/app/bin/countup"] \ No newline at end of file diff --git a/app/api/v1/countup.go b/app/api/v1/countup.go new file mode 100644 index 0000000..19d707f --- /dev/null +++ b/app/api/v1/countup.go @@ -0,0 +1,130 @@ +package apiv1 + +import ( + "embed" + + . "goa.design/goa/v3/dsl" +) + +var ( + //go:embed gen/http/*.json gen/http/*.yaml + OpenAPIFS embed.FS +) + +var _ = API("countup", func() { + Title("Count Up") + Description("A production-ready Go service deployed on Kubernetes") + Version("1.0.0") + Server("countup", func() { + Services("api", "web") + Host("dev-http", func() { + URI("http://localhost:8080") + }) + Host("dev-grpc", func() { + URI("grpc://localhost:8081") + }) + }) +}) + +var CounterInfo = ResultType("application/vnd.countup.counter-info`", "CounterInfo", func() { + Field(1, "count", Int32) + Field(2, "last_increment_by", String) + Field(3, "last_increment_at", String) + Field(4, "next_finalize_at", String) + Required("count", "last_increment_by", "last_increment_at", "next_finalize_at") +}) + +var _ = Service("api", func() { + Error("unauthorized") + Error("existing_increment_request", func() { + Temporary() + }) + + HTTP(func() { + Response("unauthorized", StatusUnauthorized) + Response("existing_increment_request", StatusTooManyRequests) + }) + + GRPC(func() { + Response("unauthorized", CodePermissionDenied) + Response("existing_increment_request", CodeAlreadyExists) + }) + + Method("CounterGet", func() { + Result(CounterInfo) + + HTTP(func() { + GET("/counter") + Response(StatusOK) + }) + + GRPC(func() { + Response(CodeOK) + }) + }) + + Method("CounterIncrement", func() { + Payload(func() { + Field(1, "user", String) + Required("user") + }) + + Result(CounterInfo) + + HTTP(func() { + POST("/counter/inc") + Response(StatusAccepted) + }) + + GRPC(func() { + Response(CodeOK) + }) + }) + + Method("Echo", func() { + Payload(func() { + Field(1, "text", String) + Required("text") + }) + + Result(func() { + Field(1, "text", String) + Required("text") + }) + + HTTP(func() { + POST("/echo") + Response(StatusOK) + }) + + GRPC(func() { + Response(CodeOK) + }) + }) + + Files("/openapi.json", "gen/http/openapi3.json") +}) + +var _ = Service("web", func() { + Method("index", func() { + Result(Bytes) + HTTP(func() { + GET("/") + Response(StatusOK, func() { + ContentType("text/html") + }) + }) + }) + + Method("another", func() { + Result(Bytes) + HTTP(func() { + GET("/another") + Response(StatusOK, func() { + ContentType("text/html") + }) + }) + }) + + Files("/static/{*path}", "static/") +}) diff --git a/app/api/v1/gen/api/client.go b/app/api/v1/gen/api/client.go new file mode 100644 index 0000000..3e8a87f --- /dev/null +++ b/app/api/v1/gen/api/client.go @@ -0,0 +1,72 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api client +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package api + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Client is the "api" service client. +type Client struct { + CounterGetEndpoint goa.Endpoint + CounterIncrementEndpoint goa.Endpoint + EchoEndpoint goa.Endpoint +} + +// NewClient initializes a "api" service client given the endpoints. +func NewClient(counterGet, counterIncrement, echo goa.Endpoint) *Client { + return &Client{ + CounterGetEndpoint: counterGet, + CounterIncrementEndpoint: counterIncrement, + EchoEndpoint: echo, + } +} + +// CounterGet calls the "CounterGet" endpoint of the "api" service. +// CounterGet may return the following errors: +// - "unauthorized" (type *goa.ServiceError) +// - "existing_increment_request" (type *goa.ServiceError) +// - error: internal error +func (c *Client) CounterGet(ctx context.Context) (res *CounterInfo, err error) { + var ires any + ires, err = c.CounterGetEndpoint(ctx, nil) + if err != nil { + return + } + return ires.(*CounterInfo), nil +} + +// CounterIncrement calls the "CounterIncrement" endpoint of the "api" service. +// CounterIncrement may return the following errors: +// - "unauthorized" (type *goa.ServiceError) +// - "existing_increment_request" (type *goa.ServiceError) +// - error: internal error +func (c *Client) CounterIncrement(ctx context.Context, p *CounterIncrementPayload) (res *CounterInfo, err error) { + var ires any + ires, err = c.CounterIncrementEndpoint(ctx, p) + if err != nil { + return + } + return ires.(*CounterInfo), nil +} + +// Echo calls the "Echo" endpoint of the "api" service. +// Echo may return the following errors: +// - "unauthorized" (type *goa.ServiceError) +// - "existing_increment_request" (type *goa.ServiceError) +// - error: internal error +func (c *Client) Echo(ctx context.Context, p *EchoPayload) (res *EchoResult, err error) { + var ires any + ires, err = c.EchoEndpoint(ctx, p) + if err != nil { + return + } + return ires.(*EchoResult), nil +} diff --git a/app/api/v1/gen/api/endpoints.go b/app/api/v1/gen/api/endpoints.go new file mode 100644 index 0000000..27587bd --- /dev/null +++ b/app/api/v1/gen/api/endpoints.go @@ -0,0 +1,73 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api endpoints +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package api + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Endpoints wraps the "api" service endpoints. +type Endpoints struct { + CounterGet goa.Endpoint + CounterIncrement goa.Endpoint + Echo goa.Endpoint +} + +// NewEndpoints wraps the methods of the "api" service with endpoints. +func NewEndpoints(s Service) *Endpoints { + return &Endpoints{ + CounterGet: NewCounterGetEndpoint(s), + CounterIncrement: NewCounterIncrementEndpoint(s), + Echo: NewEchoEndpoint(s), + } +} + +// Use applies the given middleware to all the "api" service endpoints. +func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { + e.CounterGet = m(e.CounterGet) + e.CounterIncrement = m(e.CounterIncrement) + e.Echo = m(e.Echo) +} + +// NewCounterGetEndpoint returns an endpoint function that calls the method +// "CounterGet" of service "api". +func NewCounterGetEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + res, err := s.CounterGet(ctx) + if err != nil { + return nil, err + } + vres := NewViewedCounterInfo(res, "default") + return vres, nil + } +} + +// NewCounterIncrementEndpoint returns an endpoint function that calls the +// method "CounterIncrement" of service "api". +func NewCounterIncrementEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*CounterIncrementPayload) + res, err := s.CounterIncrement(ctx, p) + if err != nil { + return nil, err + } + vres := NewViewedCounterInfo(res, "default") + return vres, nil + } +} + +// NewEchoEndpoint returns an endpoint function that calls the method "Echo" of +// service "api". +func NewEchoEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*EchoPayload) + return s.Echo(ctx, p) + } +} diff --git a/app/api/v1/gen/api/service.go b/app/api/v1/gen/api/service.go new file mode 100644 index 0000000..52c794d --- /dev/null +++ b/app/api/v1/gen/api/service.go @@ -0,0 +1,119 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api service +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package api + +import ( + "context" + + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + goa "goa.design/goa/v3/pkg" +) + +// Service is the api service interface. +type Service interface { + // CounterGet implements CounterGet. + CounterGet(context.Context) (res *CounterInfo, err error) + // CounterIncrement implements CounterIncrement. + CounterIncrement(context.Context, *CounterIncrementPayload) (res *CounterInfo, err error) + // Echo implements Echo. + Echo(context.Context, *EchoPayload) (res *EchoResult, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "countup" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "1.0.0" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "api" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [3]string{"CounterGet", "CounterIncrement", "Echo"} + +// CounterIncrementPayload is the payload type of the api service +// CounterIncrement method. +type CounterIncrementPayload struct { + User string +} + +// CounterInfo is the result type of the api service CounterGet method. +type CounterInfo struct { + Count int32 + LastIncrementBy string + LastIncrementAt string + NextFinalizeAt string +} + +// EchoPayload is the payload type of the api service Echo method. +type EchoPayload struct { + Text string +} + +// EchoResult is the result type of the api service Echo method. +type EchoResult struct { + Text string +} + +// MakeUnauthorized builds a goa.ServiceError from an error. +func MakeUnauthorized(err error) *goa.ServiceError { + return goa.NewServiceError(err, "unauthorized", false, false, false) +} + +// MakeExistingIncrementRequest builds a goa.ServiceError from an error. +func MakeExistingIncrementRequest(err error) *goa.ServiceError { + return goa.NewServiceError(err, "existing_increment_request", false, true, false) +} + +// NewCounterInfo initializes result type CounterInfo from viewed result type +// CounterInfo. +func NewCounterInfo(vres *apiviews.CounterInfo) *CounterInfo { + return newCounterInfo(vres.Projected) +} + +// NewViewedCounterInfo initializes viewed result type CounterInfo from result +// type CounterInfo using the given view. +func NewViewedCounterInfo(res *CounterInfo, view string) *apiviews.CounterInfo { + p := newCounterInfoView(res) + return &apiviews.CounterInfo{Projected: p, View: "default"} +} + +// newCounterInfo converts projected type CounterInfo to service type +// CounterInfo. +func newCounterInfo(vres *apiviews.CounterInfoView) *CounterInfo { + res := &CounterInfo{} + if vres.Count != nil { + res.Count = *vres.Count + } + if vres.LastIncrementBy != nil { + res.LastIncrementBy = *vres.LastIncrementBy + } + if vres.LastIncrementAt != nil { + res.LastIncrementAt = *vres.LastIncrementAt + } + if vres.NextFinalizeAt != nil { + res.NextFinalizeAt = *vres.NextFinalizeAt + } + return res +} + +// newCounterInfoView projects result type CounterInfo to projected type +// CounterInfoView using the "default" view. +func newCounterInfoView(res *CounterInfo) *apiviews.CounterInfoView { + vres := &apiviews.CounterInfoView{ + Count: &res.Count, + LastIncrementBy: &res.LastIncrementBy, + LastIncrementAt: &res.LastIncrementAt, + NextFinalizeAt: &res.NextFinalizeAt, + } + return vres +} diff --git a/app/api/v1/gen/api/views/view.go b/app/api/v1/gen/api/views/view.go new file mode 100644 index 0000000..9453fa3 --- /dev/null +++ b/app/api/v1/gen/api/views/view.go @@ -0,0 +1,71 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api views +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package views + +import ( + goa "goa.design/goa/v3/pkg" +) + +// CounterInfo is the viewed result type that is projected based on a view. +type CounterInfo struct { + // Type to project + Projected *CounterInfoView + // View to render + View string +} + +// CounterInfoView is a type that runs validations on a projected type. +type CounterInfoView struct { + Count *int32 + LastIncrementBy *string + LastIncrementAt *string + NextFinalizeAt *string +} + +var ( + // CounterInfoMap is a map indexing the attribute names of CounterInfo by view + // name. + CounterInfoMap = map[string][]string{ + "default": { + "count", + "last_increment_by", + "last_increment_at", + "next_finalize_at", + }, + } +) + +// ValidateCounterInfo runs the validations defined on the viewed result type +// CounterInfo. +func ValidateCounterInfo(result *CounterInfo) (err error) { + switch result.View { + case "default", "": + err = ValidateCounterInfoView(result.Projected) + default: + err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) + } + return +} + +// ValidateCounterInfoView runs the validations defined on CounterInfoView +// using the "default" view. +func ValidateCounterInfoView(result *CounterInfoView) (err error) { + if result.Count == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("count", "result")) + } + if result.LastIncrementBy == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("last_increment_by", "result")) + } + if result.LastIncrementAt == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("last_increment_at", "result")) + } + if result.NextFinalizeAt == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("next_finalize_at", "result")) + } + return +} diff --git a/app/api/v1/gen/grpc/api/client/cli.go b/app/api/v1/gen/grpc/api/client/cli.go new file mode 100644 index 0000000..0e67ec9 --- /dev/null +++ b/app/api/v1/gen/grpc/api/client/cli.go @@ -0,0 +1,55 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC client CLI support package +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "encoding/json" + "fmt" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" +) + +// BuildCounterIncrementPayload builds the payload for the api CounterIncrement +// endpoint from CLI flags. +func BuildCounterIncrementPayload(apiCounterIncrementMessage string) (*api.CounterIncrementPayload, error) { + var err error + var message apipb.CounterIncrementRequest + { + if apiCounterIncrementMessage != "" { + err = json.Unmarshal([]byte(apiCounterIncrementMessage), &message) + if err != nil { + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"user\": \"Non accusantium eos culpa autem illum architecto.\"\n }'") + } + } + } + v := &api.CounterIncrementPayload{ + User: message.User, + } + + return v, nil +} + +// BuildEchoPayload builds the payload for the api Echo endpoint from CLI flags. +func BuildEchoPayload(apiEchoMessage string) (*api.EchoPayload, error) { + var err error + var message apipb.EchoRequest + { + if apiEchoMessage != "" { + err = json.Unmarshal([]byte(apiEchoMessage), &message) + if err != nil { + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"text\": \"Cupiditate tempore harum error iste ipsam natus.\"\n }'") + } + } + } + v := &api.EchoPayload{ + Text: message.Text, + } + + return v, nil +} diff --git a/app/api/v1/gen/grpc/api/client/client.go b/app/api/v1/gen/grpc/api/client/client.go new file mode 100644 index 0000000..38b0cf8 --- /dev/null +++ b/app/api/v1/gen/grpc/api/client/client.go @@ -0,0 +1,88 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC client +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "context" + + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" + goagrpc "goa.design/goa/v3/grpc" + goapb "goa.design/goa/v3/grpc/pb" + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" +) + +// Client lists the service endpoint gRPC clients. +type Client struct { + grpccli apipb.APIClient + opts []grpc.CallOption +} // NewClient instantiates gRPC client for all the api service servers. +func NewClient(cc *grpc.ClientConn, opts ...grpc.CallOption) *Client { + return &Client{ + grpccli: apipb.NewAPIClient(cc), + opts: opts, + } +} // CounterGet calls the "CounterGet" function in apipb.APIClient interface. +func (c *Client) CounterGet() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildCounterGetFunc(c.grpccli, c.opts...), + nil, + DecodeCounterGetResponse) + res, err := inv.Invoke(ctx, v) + if err != nil { + resp := goagrpc.DecodeError(err) + switch message := resp.(type) { + case *goapb.ErrorResponse: + return nil, goagrpc.NewServiceError(message) + default: + return nil, goa.Fault(err.Error()) + } + } + return res, nil + } +} // CounterIncrement calls the "CounterIncrement" function in apipb.APIClient +// interface. +func (c *Client) CounterIncrement() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildCounterIncrementFunc(c.grpccli, c.opts...), + EncodeCounterIncrementRequest, + DecodeCounterIncrementResponse) + res, err := inv.Invoke(ctx, v) + if err != nil { + resp := goagrpc.DecodeError(err) + switch message := resp.(type) { + case *goapb.ErrorResponse: + return nil, goagrpc.NewServiceError(message) + default: + return nil, goa.Fault(err.Error()) + } + } + return res, nil + } +} // Echo calls the "Echo" function in apipb.APIClient interface. +func (c *Client) Echo() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildEchoFunc(c.grpccli, c.opts...), + EncodeEchoRequest, + DecodeEchoResponse) + res, err := inv.Invoke(ctx, v) + if err != nil { + resp := goagrpc.DecodeError(err) + switch message := resp.(type) { + case *goapb.ErrorResponse: + return nil, goagrpc.NewServiceError(message) + default: + return nil, goa.Fault(err.Error()) + } + } + return res, nil + } +} diff --git a/app/api/v1/gen/grpc/api/client/encode_decode.go b/app/api/v1/gen/grpc/api/client/encode_decode.go new file mode 100644 index 0000000..358b5b5 --- /dev/null +++ b/app/api/v1/gen/grpc/api/client/encode_decode.go @@ -0,0 +1,127 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC client encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "context" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" + goagrpc "goa.design/goa/v3/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// BuildCounterGetFunc builds the remote method to invoke for "api" service +// "CounterGet" endpoint. +func BuildCounterGetFunc(grpccli apipb.APIClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + if reqpb != nil { + return grpccli.CounterGet(ctx, reqpb.(*apipb.CounterGetRequest), opts...) + } + return grpccli.CounterGet(ctx, &apipb.CounterGetRequest{}, opts...) + } +} + +// DecodeCounterGetResponse decodes responses from the api CounterGet endpoint. +func DecodeCounterGetResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var view string + { + if vals := hdr.Get("goa-view"); len(vals) > 0 { + view = vals[0] + } + } + message, ok := v.(*apipb.CounterGetResponse) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterGet", "*apipb.CounterGetResponse", v) + } + res := NewCounterGetResult(message) + vres := &apiviews.CounterInfo{Projected: res, View: view} + if err := apiviews.ValidateCounterInfo(vres); err != nil { + return nil, err + } + return api.NewCounterInfo(vres), nil +} // BuildCounterIncrementFunc builds the remote method to invoke for "api" +// service "CounterIncrement" endpoint. +func BuildCounterIncrementFunc(grpccli apipb.APIClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + if reqpb != nil { + return grpccli.CounterIncrement(ctx, reqpb.(*apipb.CounterIncrementRequest), opts...) + } + return grpccli.CounterIncrement(ctx, &apipb.CounterIncrementRequest{}, opts...) + } +} + +// EncodeCounterIncrementRequest encodes requests sent to api CounterIncrement +// endpoint. +func EncodeCounterIncrementRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*api.CounterIncrementPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterIncrement", "*api.CounterIncrementPayload", v) + } + return NewProtoCounterIncrementRequest(payload), nil +} + +// DecodeCounterIncrementResponse decodes responses from the api +// CounterIncrement endpoint. +func DecodeCounterIncrementResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var view string + { + if vals := hdr.Get("goa-view"); len(vals) > 0 { + view = vals[0] + } + } + message, ok := v.(*apipb.CounterIncrementResponse) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterIncrement", "*apipb.CounterIncrementResponse", v) + } + res := NewCounterIncrementResult(message) + vres := &apiviews.CounterInfo{Projected: res, View: view} + if err := apiviews.ValidateCounterInfo(vres); err != nil { + return nil, err + } + return api.NewCounterInfo(vres), nil +} // BuildEchoFunc builds the remote method to invoke for "api" service "Echo" +// endpoint. +func BuildEchoFunc(grpccli apipb.APIClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + if reqpb != nil { + return grpccli.Echo(ctx, reqpb.(*apipb.EchoRequest), opts...) + } + return grpccli.Echo(ctx, &apipb.EchoRequest{}, opts...) + } +} + +// EncodeEchoRequest encodes requests sent to api Echo endpoint. +func EncodeEchoRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*api.EchoPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "Echo", "*api.EchoPayload", v) + } + return NewProtoEchoRequest(payload), nil +} + +// DecodeEchoResponse decodes responses from the api Echo endpoint. +func DecodeEchoResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + message, ok := v.(*apipb.EchoResponse) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "Echo", "*apipb.EchoResponse", v) + } + res := NewEchoResult(message) + return res, nil +} diff --git a/app/api/v1/gen/grpc/api/client/types.go b/app/api/v1/gen/grpc/api/client/types.go new file mode 100644 index 0000000..596e175 --- /dev/null +++ b/app/api/v1/gen/grpc/api/client/types.go @@ -0,0 +1,72 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC client types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" +) + +// NewProtoCounterGetRequest builds the gRPC request type from the payload of +// the "CounterGet" endpoint of the "api" service. +func NewProtoCounterGetRequest() *apipb.CounterGetRequest { + message := &apipb.CounterGetRequest{} + return message +} + +// NewCounterGetResult builds the result type of the "CounterGet" endpoint of +// the "api" service from the gRPC response type. +func NewCounterGetResult(message *apipb.CounterGetResponse) *apiviews.CounterInfoView { + result := &apiviews.CounterInfoView{ + Count: &message.Count, + LastIncrementBy: &message.LastIncrementBy, + LastIncrementAt: &message.LastIncrementAt, + NextFinalizeAt: &message.NextFinalizeAt, + } + return result +} + +// NewProtoCounterIncrementRequest builds the gRPC request type from the +// payload of the "CounterIncrement" endpoint of the "api" service. +func NewProtoCounterIncrementRequest(payload *api.CounterIncrementPayload) *apipb.CounterIncrementRequest { + message := &apipb.CounterIncrementRequest{ + User: payload.User, + } + return message +} + +// NewCounterIncrementResult builds the result type of the "CounterIncrement" +// endpoint of the "api" service from the gRPC response type. +func NewCounterIncrementResult(message *apipb.CounterIncrementResponse) *apiviews.CounterInfoView { + result := &apiviews.CounterInfoView{ + Count: &message.Count, + LastIncrementBy: &message.LastIncrementBy, + LastIncrementAt: &message.LastIncrementAt, + NextFinalizeAt: &message.NextFinalizeAt, + } + return result +} + +// NewProtoEchoRequest builds the gRPC request type from the payload of the +// "Echo" endpoint of the "api" service. +func NewProtoEchoRequest(payload *api.EchoPayload) *apipb.EchoRequest { + message := &apipb.EchoRequest{ + Text: payload.Text, + } + return message +} + +// NewEchoResult builds the result type of the "Echo" endpoint of the "api" +// service from the gRPC response type. +func NewEchoResult(message *apipb.EchoResponse) *api.EchoResult { + result := &api.EchoResult{ + Text: message.Text, + } + return result +} diff --git a/app/api/v1/gen/grpc/api/pb/goagen_v1_api.pb.go b/app/api/v1/gen/grpc/api/pb/goagen_v1_api.pb.go new file mode 100644 index 0000000..ecc0f0a --- /dev/null +++ b/app/api/v1/gen/grpc/api/pb/goagen_v1_api.pb.go @@ -0,0 +1,448 @@ +// Code generated with goa v3.19.1, DO NOT EDIT. +// +// api protocol buffer definition +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.1 +// protoc v5.28.2 +// source: goagen_v1_api.proto + +package apipb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CounterGetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *CounterGetRequest) Reset() { + *x = CounterGetRequest{} + mi := &file_goagen_v1_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CounterGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CounterGetRequest) ProtoMessage() {} + +func (x *CounterGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CounterGetRequest.ProtoReflect.Descriptor instead. +func (*CounterGetRequest) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{0} +} + +type CounterGetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Count int32 `protobuf:"zigzag32,1,opt,name=count,proto3" json:"count,omitempty"` + LastIncrementBy string `protobuf:"bytes,2,opt,name=last_increment_by,json=lastIncrementBy,proto3" json:"last_increment_by,omitempty"` + LastIncrementAt string `protobuf:"bytes,3,opt,name=last_increment_at,json=lastIncrementAt,proto3" json:"last_increment_at,omitempty"` + NextFinalizeAt string `protobuf:"bytes,4,opt,name=next_finalize_at,json=nextFinalizeAt,proto3" json:"next_finalize_at,omitempty"` +} + +func (x *CounterGetResponse) Reset() { + *x = CounterGetResponse{} + mi := &file_goagen_v1_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CounterGetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CounterGetResponse) ProtoMessage() {} + +func (x *CounterGetResponse) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CounterGetResponse.ProtoReflect.Descriptor instead. +func (*CounterGetResponse) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{1} +} + +func (x *CounterGetResponse) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *CounterGetResponse) GetLastIncrementBy() string { + if x != nil { + return x.LastIncrementBy + } + return "" +} + +func (x *CounterGetResponse) GetLastIncrementAt() string { + if x != nil { + return x.LastIncrementAt + } + return "" +} + +func (x *CounterGetResponse) GetNextFinalizeAt() string { + if x != nil { + return x.NextFinalizeAt + } + return "" +} + +type CounterIncrementRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` +} + +func (x *CounterIncrementRequest) Reset() { + *x = CounterIncrementRequest{} + mi := &file_goagen_v1_api_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CounterIncrementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CounterIncrementRequest) ProtoMessage() {} + +func (x *CounterIncrementRequest) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CounterIncrementRequest.ProtoReflect.Descriptor instead. +func (*CounterIncrementRequest) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{2} +} + +func (x *CounterIncrementRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +type CounterIncrementResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Count int32 `protobuf:"zigzag32,1,opt,name=count,proto3" json:"count,omitempty"` + LastIncrementBy string `protobuf:"bytes,2,opt,name=last_increment_by,json=lastIncrementBy,proto3" json:"last_increment_by,omitempty"` + LastIncrementAt string `protobuf:"bytes,3,opt,name=last_increment_at,json=lastIncrementAt,proto3" json:"last_increment_at,omitempty"` + NextFinalizeAt string `protobuf:"bytes,4,opt,name=next_finalize_at,json=nextFinalizeAt,proto3" json:"next_finalize_at,omitempty"` +} + +func (x *CounterIncrementResponse) Reset() { + *x = CounterIncrementResponse{} + mi := &file_goagen_v1_api_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CounterIncrementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CounterIncrementResponse) ProtoMessage() {} + +func (x *CounterIncrementResponse) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CounterIncrementResponse.ProtoReflect.Descriptor instead. +func (*CounterIncrementResponse) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{3} +} + +func (x *CounterIncrementResponse) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *CounterIncrementResponse) GetLastIncrementBy() string { + if x != nil { + return x.LastIncrementBy + } + return "" +} + +func (x *CounterIncrementResponse) GetLastIncrementAt() string { + if x != nil { + return x.LastIncrementAt + } + return "" +} + +func (x *CounterIncrementResponse) GetNextFinalizeAt() string { + if x != nil { + return x.NextFinalizeAt + } + return "" +} + +type EchoRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *EchoRequest) Reset() { + *x = EchoRequest{} + mi := &file_goagen_v1_api_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchoRequest) ProtoMessage() {} + +func (x *EchoRequest) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchoRequest.ProtoReflect.Descriptor instead. +func (*EchoRequest) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{4} +} + +func (x *EchoRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type EchoResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *EchoResponse) Reset() { + *x = EchoResponse{} + mi := &file_goagen_v1_api_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EchoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EchoResponse) ProtoMessage() {} + +func (x *EchoResponse) ProtoReflect() protoreflect.Message { + mi := &file_goagen_v1_api_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EchoResponse.ProtoReflect.Descriptor instead. +func (*EchoResponse) Descriptor() ([]byte, []int) { + return file_goagen_v1_api_proto_rawDescGZIP(), []int{5} +} + +func (x *EchoResponse) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +var File_goagen_v1_api_proto protoreflect.FileDescriptor + +var file_goagen_v1_api_proto_rawDesc = []byte{ + 0x0a, 0x13, 0x67, 0x6f, 0x61, 0x67, 0x65, 0x6e, 0x5f, 0x76, 0x31, 0x5f, 0x61, 0x70, 0x69, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0x13, 0x0a, 0x11, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x65, 0x72, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, + 0xac, 0x01, 0x0a, 0x12, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x47, 0x65, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x11, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2a, 0x0a, 0x11, + 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x69, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x62, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x63, + 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x79, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x69, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x41, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x66, 0x69, 0x6e, + 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, + 0x6e, 0x65, 0x78, 0x74, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x74, 0x22, 0x2d, + 0x0a, 0x17, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0xb2, 0x01, + 0x0a, 0x18, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x11, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x69, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73, + 0x74, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x79, 0x12, 0x2a, 0x0a, 0x11, + 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x69, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x61, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x63, + 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x41, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x6e, 0x65, 0x78, 0x74, + 0x5f, 0x66, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x78, 0x74, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, + 0x41, 0x74, 0x22, 0x21, 0x0a, 0x0b, 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x22, 0x0a, 0x0c, 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x32, 0xc2, 0x01, 0x0a, 0x03, 0x41, 0x50, + 0x49, 0x12, 0x3d, 0x0a, 0x0a, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x47, 0x65, 0x74, 0x12, + 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x47, 0x65, 0x74, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x65, 0x72, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x4f, 0x0a, 0x10, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x49, 0x6e, 0x63, 0x72, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x65, 0x72, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, + 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x2b, 0x0a, 0x04, 0x45, 0x63, 0x68, 0x6f, 0x12, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x11, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x45, 0x63, 0x68, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, + 0x5a, 0x06, 0x2f, 0x61, 0x70, 0x69, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_goagen_v1_api_proto_rawDescOnce sync.Once + file_goagen_v1_api_proto_rawDescData = file_goagen_v1_api_proto_rawDesc +) + +func file_goagen_v1_api_proto_rawDescGZIP() []byte { + file_goagen_v1_api_proto_rawDescOnce.Do(func() { + file_goagen_v1_api_proto_rawDescData = protoimpl.X.CompressGZIP(file_goagen_v1_api_proto_rawDescData) + }) + return file_goagen_v1_api_proto_rawDescData +} + +var file_goagen_v1_api_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_goagen_v1_api_proto_goTypes = []any{ + (*CounterGetRequest)(nil), // 0: api.CounterGetRequest + (*CounterGetResponse)(nil), // 1: api.CounterGetResponse + (*CounterIncrementRequest)(nil), // 2: api.CounterIncrementRequest + (*CounterIncrementResponse)(nil), // 3: api.CounterIncrementResponse + (*EchoRequest)(nil), // 4: api.EchoRequest + (*EchoResponse)(nil), // 5: api.EchoResponse +} +var file_goagen_v1_api_proto_depIdxs = []int32{ + 0, // 0: api.API.CounterGet:input_type -> api.CounterGetRequest + 2, // 1: api.API.CounterIncrement:input_type -> api.CounterIncrementRequest + 4, // 2: api.API.Echo:input_type -> api.EchoRequest + 1, // 3: api.API.CounterGet:output_type -> api.CounterGetResponse + 3, // 4: api.API.CounterIncrement:output_type -> api.CounterIncrementResponse + 5, // 5: api.API.Echo:output_type -> api.EchoResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_goagen_v1_api_proto_init() } +func file_goagen_v1_api_proto_init() { + if File_goagen_v1_api_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_goagen_v1_api_proto_rawDesc, + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_goagen_v1_api_proto_goTypes, + DependencyIndexes: file_goagen_v1_api_proto_depIdxs, + MessageInfos: file_goagen_v1_api_proto_msgTypes, + }.Build() + File_goagen_v1_api_proto = out.File + file_goagen_v1_api_proto_rawDesc = nil + file_goagen_v1_api_proto_goTypes = nil + file_goagen_v1_api_proto_depIdxs = nil +} diff --git a/app/api/v1/gen/grpc/api/pb/goagen_v1_api.proto b/app/api/v1/gen/grpc/api/pb/goagen_v1_api.proto new file mode 100644 index 0000000..ec2860b --- /dev/null +++ b/app/api/v1/gen/grpc/api/pb/goagen_v1_api.proto @@ -0,0 +1,51 @@ +// Code generated with goa v3.19.1, DO NOT EDIT. +// +// api protocol buffer definition +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +syntax = "proto3"; + +package api; + +option go_package = "/apipb"; + +// Service is the api service interface. +service API { + // CounterGet implements CounterGet. + rpc CounterGet (CounterGetRequest) returns (CounterGetResponse); + // CounterIncrement implements CounterIncrement. + rpc CounterIncrement (CounterIncrementRequest) returns (CounterIncrementResponse); + // Echo implements Echo. + rpc Echo (EchoRequest) returns (EchoResponse); +} + +message CounterGetRequest { +} + +message CounterGetResponse { + sint32 count = 1; + string last_increment_by = 2; + string last_increment_at = 3; + string next_finalize_at = 4; +} + +message CounterIncrementRequest { + string user = 1; +} + +message CounterIncrementResponse { + sint32 count = 1; + string last_increment_by = 2; + string last_increment_at = 3; + string next_finalize_at = 4; +} + +message EchoRequest { + string text = 1; +} + +message EchoResponse { + string text = 1; +} diff --git a/app/api/v1/gen/grpc/api/pb/goagen_v1_api_grpc.pb.go b/app/api/v1/gen/grpc/api/pb/goagen_v1_api_grpc.pb.go new file mode 100644 index 0000000..eed11d7 --- /dev/null +++ b/app/api/v1/gen/grpc/api/pb/goagen_v1_api_grpc.pb.go @@ -0,0 +1,214 @@ +// Code generated with goa v3.19.1, DO NOT EDIT. +// +// api protocol buffer definition +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.2 +// source: goagen_v1_api.proto + +package apipb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + API_CounterGet_FullMethodName = "/api.API/CounterGet" + API_CounterIncrement_FullMethodName = "/api.API/CounterIncrement" + API_Echo_FullMethodName = "/api.API/Echo" +) + +// APIClient is the client API for API service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Service is the api service interface. +type APIClient interface { + // CounterGet implements CounterGet. + CounterGet(ctx context.Context, in *CounterGetRequest, opts ...grpc.CallOption) (*CounterGetResponse, error) + // CounterIncrement implements CounterIncrement. + CounterIncrement(ctx context.Context, in *CounterIncrementRequest, opts ...grpc.CallOption) (*CounterIncrementResponse, error) + // Echo implements Echo. + Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoResponse, error) +} + +type aPIClient struct { + cc grpc.ClientConnInterface +} + +func NewAPIClient(cc grpc.ClientConnInterface) APIClient { + return &aPIClient{cc} +} + +func (c *aPIClient) CounterGet(ctx context.Context, in *CounterGetRequest, opts ...grpc.CallOption) (*CounterGetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CounterGetResponse) + err := c.cc.Invoke(ctx, API_CounterGet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aPIClient) CounterIncrement(ctx context.Context, in *CounterIncrementRequest, opts ...grpc.CallOption) (*CounterIncrementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CounterIncrementResponse) + err := c.cc.Invoke(ctx, API_CounterIncrement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aPIClient) Echo(ctx context.Context, in *EchoRequest, opts ...grpc.CallOption) (*EchoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EchoResponse) + err := c.cc.Invoke(ctx, API_Echo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// APIServer is the server API for API service. +// All implementations must embed UnimplementedAPIServer +// for forward compatibility. +// +// Service is the api service interface. +type APIServer interface { + // CounterGet implements CounterGet. + CounterGet(context.Context, *CounterGetRequest) (*CounterGetResponse, error) + // CounterIncrement implements CounterIncrement. + CounterIncrement(context.Context, *CounterIncrementRequest) (*CounterIncrementResponse, error) + // Echo implements Echo. + Echo(context.Context, *EchoRequest) (*EchoResponse, error) + mustEmbedUnimplementedAPIServer() +} + +// UnimplementedAPIServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAPIServer struct{} + +func (UnimplementedAPIServer) CounterGet(context.Context, *CounterGetRequest) (*CounterGetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CounterGet not implemented") +} +func (UnimplementedAPIServer) CounterIncrement(context.Context, *CounterIncrementRequest) (*CounterIncrementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CounterIncrement not implemented") +} +func (UnimplementedAPIServer) Echo(context.Context, *EchoRequest) (*EchoResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Echo not implemented") +} +func (UnimplementedAPIServer) mustEmbedUnimplementedAPIServer() {} +func (UnimplementedAPIServer) testEmbeddedByValue() {} + +// UnsafeAPIServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to APIServer will +// result in compilation errors. +type UnsafeAPIServer interface { + mustEmbedUnimplementedAPIServer() +} + +func RegisterAPIServer(s grpc.ServiceRegistrar, srv APIServer) { + // If the following call pancis, it indicates UnimplementedAPIServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&API_ServiceDesc, srv) +} + +func _API_CounterGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CounterGetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(APIServer).CounterGet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: API_CounterGet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(APIServer).CounterGet(ctx, req.(*CounterGetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _API_CounterIncrement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CounterIncrementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(APIServer).CounterIncrement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: API_CounterIncrement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(APIServer).CounterIncrement(ctx, req.(*CounterIncrementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _API_Echo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EchoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(APIServer).Echo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: API_Echo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(APIServer).Echo(ctx, req.(*EchoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// API_ServiceDesc is the grpc.ServiceDesc for API service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var API_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "api.API", + HandlerType: (*APIServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CounterGet", + Handler: _API_CounterGet_Handler, + }, + { + MethodName: "CounterIncrement", + Handler: _API_CounterIncrement_Handler, + }, + { + MethodName: "Echo", + Handler: _API_Echo_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "goagen_v1_api.proto", +} diff --git a/app/api/v1/gen/grpc/api/server/encode_decode.go b/app/api/v1/gen/grpc/api/server/encode_decode.go new file mode 100644 index 0000000..2c40680 --- /dev/null +++ b/app/api/v1/gen/grpc/api/server/encode_decode.go @@ -0,0 +1,91 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC server encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" + goagrpc "goa.design/goa/v3/grpc" + "google.golang.org/grpc/metadata" +) + +// EncodeCounterGetResponse encodes responses from the "api" service +// "CounterGet" endpoint. +func EncodeCounterGetResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*apiviews.CounterInfo) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterGet", "*apiviews.CounterInfo", v) + } + result := vres.Projected + (*hdr).Append("goa-view", vres.View) + resp := NewProtoCounterGetResponse(result) + return resp, nil +} + +// EncodeCounterIncrementResponse encodes responses from the "api" service +// "CounterIncrement" endpoint. +func EncodeCounterIncrementResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*apiviews.CounterInfo) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterIncrement", "*apiviews.CounterInfo", v) + } + result := vres.Projected + (*hdr).Append("goa-view", vres.View) + resp := NewProtoCounterIncrementResponse(result) + return resp, nil +} + +// DecodeCounterIncrementRequest decodes requests sent to "api" service +// "CounterIncrement" endpoint. +func DecodeCounterIncrementRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + message *apipb.CounterIncrementRequest + ok bool + ) + { + if message, ok = v.(*apipb.CounterIncrementRequest); !ok { + return nil, goagrpc.ErrInvalidType("api", "CounterIncrement", "*apipb.CounterIncrementRequest", v) + } + } + var payload *api.CounterIncrementPayload + { + payload = NewCounterIncrementPayload(message) + } + return payload, nil +} + +// EncodeEchoResponse encodes responses from the "api" service "Echo" endpoint. +func EncodeEchoResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + result, ok := v.(*api.EchoResult) + if !ok { + return nil, goagrpc.ErrInvalidType("api", "Echo", "*api.EchoResult", v) + } + resp := NewProtoEchoResponse(result) + return resp, nil +} + +// DecodeEchoRequest decodes requests sent to "api" service "Echo" endpoint. +func DecodeEchoRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + message *apipb.EchoRequest + ok bool + ) + { + if message, ok = v.(*apipb.EchoRequest); !ok { + return nil, goagrpc.ErrInvalidType("api", "Echo", "*apipb.EchoRequest", v) + } + } + var payload *api.EchoPayload + { + payload = NewEchoPayload(message) + } + return payload, nil +} diff --git a/app/api/v1/gen/grpc/api/server/server.go b/app/api/v1/gen/grpc/api/server/server.go new file mode 100644 index 0000000..dcbceb8 --- /dev/null +++ b/app/api/v1/gen/grpc/api/server/server.go @@ -0,0 +1,124 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC server +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + "errors" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" + goagrpc "goa.design/goa/v3/grpc" + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc/codes" +) + +// Server implements the apipb.APIServer interface. +type Server struct { + CounterGetH goagrpc.UnaryHandler + CounterIncrementH goagrpc.UnaryHandler + EchoH goagrpc.UnaryHandler + apipb.UnimplementedAPIServer +} + +// New instantiates the server struct with the api service endpoints. +func New(e *api.Endpoints, uh goagrpc.UnaryHandler) *Server { + return &Server{ + CounterGetH: NewCounterGetHandler(e.CounterGet, uh), + CounterIncrementH: NewCounterIncrementHandler(e.CounterIncrement, uh), + EchoH: NewEchoHandler(e.Echo, uh), + } +} + +// NewCounterGetHandler creates a gRPC handler which serves the "api" service +// "CounterGet" endpoint. +func NewCounterGetHandler(endpoint goa.Endpoint, h goagrpc.UnaryHandler) goagrpc.UnaryHandler { + if h == nil { + h = goagrpc.NewUnaryHandler(endpoint, nil, EncodeCounterGetResponse) + } + return h +} + +// CounterGet implements the "CounterGet" method in apipb.APIServer interface. +func (s *Server) CounterGet(ctx context.Context, message *apipb.CounterGetRequest) (*apipb.CounterGetResponse, error) { + ctx = context.WithValue(ctx, goa.MethodKey, "CounterGet") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + resp, err := s.CounterGetH.Handle(ctx, message) + if err != nil { + var en goa.GoaErrorNamer + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "unauthorized": + return nil, goagrpc.NewStatusError(codes.PermissionDenied, err, goagrpc.NewErrorResponse(err)) + case "existing_increment_request": + return nil, goagrpc.NewStatusError(codes.AlreadyExists, err, goagrpc.NewErrorResponse(err)) + } + } + return nil, goagrpc.EncodeError(err) + } + return resp.(*apipb.CounterGetResponse), nil +} + +// NewCounterIncrementHandler creates a gRPC handler which serves the "api" +// service "CounterIncrement" endpoint. +func NewCounterIncrementHandler(endpoint goa.Endpoint, h goagrpc.UnaryHandler) goagrpc.UnaryHandler { + if h == nil { + h = goagrpc.NewUnaryHandler(endpoint, DecodeCounterIncrementRequest, EncodeCounterIncrementResponse) + } + return h +} + +// CounterIncrement implements the "CounterIncrement" method in apipb.APIServer +// interface. +func (s *Server) CounterIncrement(ctx context.Context, message *apipb.CounterIncrementRequest) (*apipb.CounterIncrementResponse, error) { + ctx = context.WithValue(ctx, goa.MethodKey, "CounterIncrement") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + resp, err := s.CounterIncrementH.Handle(ctx, message) + if err != nil { + var en goa.GoaErrorNamer + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "unauthorized": + return nil, goagrpc.NewStatusError(codes.PermissionDenied, err, goagrpc.NewErrorResponse(err)) + case "existing_increment_request": + return nil, goagrpc.NewStatusError(codes.AlreadyExists, err, goagrpc.NewErrorResponse(err)) + } + } + return nil, goagrpc.EncodeError(err) + } + return resp.(*apipb.CounterIncrementResponse), nil +} + +// NewEchoHandler creates a gRPC handler which serves the "api" service "Echo" +// endpoint. +func NewEchoHandler(endpoint goa.Endpoint, h goagrpc.UnaryHandler) goagrpc.UnaryHandler { + if h == nil { + h = goagrpc.NewUnaryHandler(endpoint, DecodeEchoRequest, EncodeEchoResponse) + } + return h +} + +// Echo implements the "Echo" method in apipb.APIServer interface. +func (s *Server) Echo(ctx context.Context, message *apipb.EchoRequest) (*apipb.EchoResponse, error) { + ctx = context.WithValue(ctx, goa.MethodKey, "Echo") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + resp, err := s.EchoH.Handle(ctx, message) + if err != nil { + var en goa.GoaErrorNamer + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "unauthorized": + return nil, goagrpc.NewStatusError(codes.PermissionDenied, err, goagrpc.NewErrorResponse(err)) + case "existing_increment_request": + return nil, goagrpc.NewStatusError(codes.AlreadyExists, err, goagrpc.NewErrorResponse(err)) + } + } + return nil, goagrpc.EncodeError(err) + } + return resp.(*apipb.EchoResponse), nil +} diff --git a/app/api/v1/gen/grpc/api/server/types.go b/app/api/v1/gen/grpc/api/server/types.go new file mode 100644 index 0000000..9b23b3a --- /dev/null +++ b/app/api/v1/gen/grpc/api/server/types.go @@ -0,0 +1,65 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api gRPC server types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" +) + +// NewProtoCounterGetResponse builds the gRPC response type from the result of +// the "CounterGet" endpoint of the "api" service. +func NewProtoCounterGetResponse(result *apiviews.CounterInfoView) *apipb.CounterGetResponse { + message := &apipb.CounterGetResponse{ + Count: *result.Count, + LastIncrementBy: *result.LastIncrementBy, + LastIncrementAt: *result.LastIncrementAt, + NextFinalizeAt: *result.NextFinalizeAt, + } + return message +} + +// NewCounterIncrementPayload builds the payload of the "CounterIncrement" +// endpoint of the "api" service from the gRPC request type. +func NewCounterIncrementPayload(message *apipb.CounterIncrementRequest) *api.CounterIncrementPayload { + v := &api.CounterIncrementPayload{ + User: message.User, + } + return v +} + +// NewProtoCounterIncrementResponse builds the gRPC response type from the +// result of the "CounterIncrement" endpoint of the "api" service. +func NewProtoCounterIncrementResponse(result *apiviews.CounterInfoView) *apipb.CounterIncrementResponse { + message := &apipb.CounterIncrementResponse{ + Count: *result.Count, + LastIncrementBy: *result.LastIncrementBy, + LastIncrementAt: *result.LastIncrementAt, + NextFinalizeAt: *result.NextFinalizeAt, + } + return message +} + +// NewEchoPayload builds the payload of the "Echo" endpoint of the "api" +// service from the gRPC request type. +func NewEchoPayload(message *apipb.EchoRequest) *api.EchoPayload { + v := &api.EchoPayload{ + Text: message.Text, + } + return v +} + +// NewProtoEchoResponse builds the gRPC response type from the result of the +// "Echo" endpoint of the "api" service. +func NewProtoEchoResponse(result *api.EchoResult) *apipb.EchoResponse { + message := &apipb.EchoResponse{ + Text: result.Text, + } + return message +} diff --git a/app/api/v1/gen/grpc/cli/countup/cli.go b/app/api/v1/gen/grpc/cli/countup/cli.go new file mode 100644 index 0000000..232bfc0 --- /dev/null +++ b/app/api/v1/gen/grpc/cli/countup/cli.go @@ -0,0 +1,186 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// countup gRPC client CLI support package +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package cli + +import ( + "flag" + "fmt" + "os" + + apic "github.com/jace-ys/countup/api/v1/gen/grpc/api/client" + goa "goa.design/goa/v3/pkg" + grpc "google.golang.org/grpc" +) + +// UsageCommands returns the set of commands and sub-commands using the format +// +// command (subcommand1|subcommand2|...) +func UsageCommands() string { + return `api (counter-get|counter-increment|echo) +` +} + +// UsageExamples produces an example of a valid invocation of the CLI tool. +func UsageExamples() string { + return os.Args[0] + ` api counter-get` + "\n" + + "" +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint(cc *grpc.ClientConn, opts ...grpc.CallOption) (goa.Endpoint, any, error) { + var ( + apiFlags = flag.NewFlagSet("api", flag.ContinueOnError) + + apiCounterGetFlags = flag.NewFlagSet("counter-get", flag.ExitOnError) + + apiCounterIncrementFlags = flag.NewFlagSet("counter-increment", flag.ExitOnError) + apiCounterIncrementMessageFlag = apiCounterIncrementFlags.String("message", "", "") + + apiEchoFlags = flag.NewFlagSet("echo", flag.ExitOnError) + apiEchoMessageFlag = apiEchoFlags.String("message", "", "") + ) + apiFlags.Usage = apiUsage + apiCounterGetFlags.Usage = apiCounterGetUsage + apiCounterIncrementFlags.Usage = apiCounterIncrementUsage + apiEchoFlags.Usage = apiEchoUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "api": + svcf = apiFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "api": + switch epn { + case "counter-get": + epf = apiCounterGetFlags + + case "counter-increment": + epf = apiCounterIncrementFlags + + case "echo": + epf = apiEchoFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "api": + c := apic.NewClient(cc, opts...) + switch epn { + case "counter-get": + endpoint = c.CounterGet() + case "counter-increment": + endpoint = c.CounterIncrement() + data, err = apic.BuildCounterIncrementPayload(*apiCounterIncrementMessageFlag) + case "echo": + endpoint = c.Echo() + data, err = apic.BuildEchoPayload(*apiEchoMessageFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} // apiUsage displays the usage of the api command and its subcommands. +func apiUsage() { + fmt.Fprintf(os.Stderr, `Service is the api service interface. +Usage: + %[1]s [globalflags] api COMMAND [flags] + +COMMAND: + counter-get: CounterGet implements CounterGet. + counter-increment: CounterIncrement implements CounterIncrement. + echo: Echo implements Echo. + +Additional help: + %[1]s api COMMAND --help +`, os.Args[0]) +} +func apiCounterGetUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api counter-get + +CounterGet implements CounterGet. + +Example: + %[1]s api counter-get +`, os.Args[0]) +} + +func apiCounterIncrementUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api counter-increment -message JSON + +CounterIncrement implements CounterIncrement. + -message JSON: + +Example: + %[1]s api counter-increment --message '{ + "user": "Non accusantium eos culpa autem illum architecto." + }' +`, os.Args[0]) +} + +func apiEchoUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api echo -message JSON + +Echo implements Echo. + -message JSON: + +Example: + %[1]s api echo --message '{ + "text": "Cupiditate tempore harum error iste ipsam natus." + }' +`, os.Args[0]) +} diff --git a/app/api/v1/gen/http/api/client/cli.go b/app/api/v1/gen/http/api/client/cli.go new file mode 100644 index 0000000..58489ba --- /dev/null +++ b/app/api/v1/gen/http/api/client/cli.go @@ -0,0 +1,50 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP client CLI support package +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "encoding/json" + "fmt" + + api "github.com/jace-ys/countup/api/v1/gen/api" +) + +// BuildCounterIncrementPayload builds the payload for the api CounterIncrement +// endpoint from CLI flags. +func BuildCounterIncrementPayload(apiCounterIncrementBody string) (*api.CounterIncrementPayload, error) { + var err error + var body CounterIncrementRequestBody + { + err = json.Unmarshal([]byte(apiCounterIncrementBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"user\": \"Nihil doloribus et sed sequi consequatur.\"\n }'") + } + } + v := &api.CounterIncrementPayload{ + User: body.User, + } + + return v, nil +} + +// BuildEchoPayload builds the payload for the api Echo endpoint from CLI flags. +func BuildEchoPayload(apiEchoBody string) (*api.EchoPayload, error) { + var err error + var body EchoRequestBody + { + err = json.Unmarshal([]byte(apiEchoBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"text\": \"Vel omnis quo sit.\"\n }'") + } + } + v := &api.EchoPayload{ + Text: body.Text, + } + + return v, nil +} diff --git a/app/api/v1/gen/http/api/client/client.go b/app/api/v1/gen/http/api/client/client.go new file mode 100644 index 0000000..6de9935 --- /dev/null +++ b/app/api/v1/gen/http/api/client/client.go @@ -0,0 +1,127 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api client HTTP transport +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "context" + "net/http" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// Client lists the api service endpoint HTTP clients. +type Client struct { + // CounterGet Doer is the HTTP client used to make requests to the CounterGet + // endpoint. + CounterGetDoer goahttp.Doer + + // CounterIncrement Doer is the HTTP client used to make requests to the + // CounterIncrement endpoint. + CounterIncrementDoer goahttp.Doer + + // Echo Doer is the HTTP client used to make requests to the Echo endpoint. + EchoDoer goahttp.Doer + + // RestoreResponseBody controls whether the response bodies are reset after + // decoding so they can be read again. + RestoreResponseBody bool + + scheme string + host string + encoder func(*http.Request) goahttp.Encoder + decoder func(*http.Response) goahttp.Decoder +} + +// NewClient instantiates HTTP clients for all the api service servers. +func NewClient( + scheme string, + host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restoreBody bool, +) *Client { + return &Client{ + CounterGetDoer: doer, + CounterIncrementDoer: doer, + EchoDoer: doer, + RestoreResponseBody: restoreBody, + scheme: scheme, + host: host, + decoder: dec, + encoder: enc, + } +} + +// CounterGet returns an endpoint that makes HTTP requests to the api service +// CounterGet server. +func (c *Client) CounterGet() goa.Endpoint { + var ( + decodeResponse = DecodeCounterGetResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCounterGetRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.CounterGetDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("api", "CounterGet", err) + } + return decodeResponse(resp) + } +} + +// CounterIncrement returns an endpoint that makes HTTP requests to the api +// service CounterIncrement server. +func (c *Client) CounterIncrement() goa.Endpoint { + var ( + encodeRequest = EncodeCounterIncrementRequest(c.encoder) + decodeResponse = DecodeCounterIncrementResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCounterIncrementRequest(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.CounterIncrementDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("api", "CounterIncrement", err) + } + return decodeResponse(resp) + } +} + +// Echo returns an endpoint that makes HTTP requests to the api service Echo +// server. +func (c *Client) Echo() goa.Endpoint { + var ( + encodeRequest = EncodeEchoRequest(c.encoder) + decodeResponse = DecodeEchoResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildEchoRequest(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.EchoDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("api", "Echo", err) + } + return decodeResponse(resp) + } +} diff --git a/app/api/v1/gen/http/api/client/encode_decode.go b/app/api/v1/gen/http/api/client/encode_decode.go new file mode 100644 index 0000000..dd511c9 --- /dev/null +++ b/app/api/v1/gen/http/api/client/encode_decode.go @@ -0,0 +1,317 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP client encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + goahttp "goa.design/goa/v3/http" +) + +// BuildCounterGetRequest instantiates a HTTP request object with method and +// path set to call the "api" service "CounterGet" endpoint +func (c *Client) BuildCounterGetRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CounterGetAPIPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("api", "CounterGet", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeCounterGetResponse returns a decoder for responses returned by the api +// CounterGet endpoint. restoreBody controls whether the response body should +// be restored after having been read. +// DecodeCounterGetResponse may return the following errors: +// - "unauthorized" (type *goa.ServiceError): http.StatusUnauthorized +// - "existing_increment_request" (type *goa.ServiceError): http.StatusTooManyRequests +// - error: internal error +func DecodeCounterGetResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body CounterGetResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterGet", err) + } + p := NewCounterGetCounterInfoOK(&body) + view := "default" + vres := &apiviews.CounterInfo{Projected: p, View: view} + if err = apiviews.ValidateCounterInfo(vres); err != nil { + return nil, goahttp.ErrValidationError("api", "CounterGet", err) + } + res := api.NewCounterInfo(vres) + return res, nil + case http.StatusUnauthorized: + var ( + body CounterGetUnauthorizedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterGet", err) + } + err = ValidateCounterGetUnauthorizedResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "CounterGet", err) + } + return nil, NewCounterGetUnauthorized(&body) + case http.StatusTooManyRequests: + var ( + body CounterGetExistingIncrementRequestResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterGet", err) + } + err = ValidateCounterGetExistingIncrementRequestResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "CounterGet", err) + } + return nil, NewCounterGetExistingIncrementRequest(&body) + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("api", "CounterGet", resp.StatusCode, string(body)) + } + } +} + +// BuildCounterIncrementRequest instantiates a HTTP request object with method +// and path set to call the "api" service "CounterIncrement" endpoint +func (c *Client) BuildCounterIncrementRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CounterIncrementAPIPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("api", "CounterIncrement", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeCounterIncrementRequest returns an encoder for requests sent to the +// api CounterIncrement server. +func EncodeCounterIncrementRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*api.CounterIncrementPayload) + if !ok { + return goahttp.ErrInvalidType("api", "CounterIncrement", "*api.CounterIncrementPayload", v) + } + body := NewCounterIncrementRequestBody(p) + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("api", "CounterIncrement", err) + } + return nil + } +} + +// DecodeCounterIncrementResponse returns a decoder for responses returned by +// the api CounterIncrement endpoint. restoreBody controls whether the response +// body should be restored after having been read. +// DecodeCounterIncrementResponse may return the following errors: +// - "unauthorized" (type *goa.ServiceError): http.StatusUnauthorized +// - "existing_increment_request" (type *goa.ServiceError): http.StatusTooManyRequests +// - error: internal error +func DecodeCounterIncrementResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusAccepted: + var ( + body CounterIncrementResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterIncrement", err) + } + p := NewCounterIncrementCounterInfoAccepted(&body) + view := "default" + vres := &apiviews.CounterInfo{Projected: p, View: view} + if err = apiviews.ValidateCounterInfo(vres); err != nil { + return nil, goahttp.ErrValidationError("api", "CounterIncrement", err) + } + res := api.NewCounterInfo(vres) + return res, nil + case http.StatusUnauthorized: + var ( + body CounterIncrementUnauthorizedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterIncrement", err) + } + err = ValidateCounterIncrementUnauthorizedResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "CounterIncrement", err) + } + return nil, NewCounterIncrementUnauthorized(&body) + case http.StatusTooManyRequests: + var ( + body CounterIncrementExistingIncrementRequestResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "CounterIncrement", err) + } + err = ValidateCounterIncrementExistingIncrementRequestResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "CounterIncrement", err) + } + return nil, NewCounterIncrementExistingIncrementRequest(&body) + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("api", "CounterIncrement", resp.StatusCode, string(body)) + } + } +} + +// BuildEchoRequest instantiates a HTTP request object with method and path set +// to call the "api" service "Echo" endpoint +func (c *Client) BuildEchoRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: EchoAPIPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("api", "Echo", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeEchoRequest returns an encoder for requests sent to the api Echo +// server. +func EncodeEchoRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*api.EchoPayload) + if !ok { + return goahttp.ErrInvalidType("api", "Echo", "*api.EchoPayload", v) + } + body := NewEchoRequestBody(p) + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("api", "Echo", err) + } + return nil + } +} + +// DecodeEchoResponse returns a decoder for responses returned by the api Echo +// endpoint. restoreBody controls whether the response body should be restored +// after having been read. +// DecodeEchoResponse may return the following errors: +// - "unauthorized" (type *goa.ServiceError): http.StatusUnauthorized +// - "existing_increment_request" (type *goa.ServiceError): http.StatusTooManyRequests +// - error: internal error +func DecodeEchoResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body EchoResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "Echo", err) + } + err = ValidateEchoResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "Echo", err) + } + res := NewEchoResultOK(&body) + return res, nil + case http.StatusUnauthorized: + var ( + body EchoUnauthorizedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "Echo", err) + } + err = ValidateEchoUnauthorizedResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "Echo", err) + } + return nil, NewEchoUnauthorized(&body) + case http.StatusTooManyRequests: + var ( + body EchoExistingIncrementRequestResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("api", "Echo", err) + } + err = ValidateEchoExistingIncrementRequestResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("api", "Echo", err) + } + return nil, NewEchoExistingIncrementRequest(&body) + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("api", "Echo", resp.StatusCode, string(body)) + } + } +} diff --git a/app/api/v1/gen/http/api/client/paths.go b/app/api/v1/gen/http/api/client/paths.go new file mode 100644 index 0000000..0f1045e --- /dev/null +++ b/app/api/v1/gen/http/api/client/paths.go @@ -0,0 +1,23 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// HTTP request path constructors for the api service. +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +// CounterGetAPIPath returns the URL path to the api service CounterGet HTTP endpoint. +func CounterGetAPIPath() string { + return "/counter" +} + +// CounterIncrementAPIPath returns the URL path to the api service CounterIncrement HTTP endpoint. +func CounterIncrementAPIPath() string { + return "/counter/inc" +} + +// EchoAPIPath returns the URL path to the api service Echo HTTP endpoint. +func EchoAPIPath() string { + return "/echo" +} diff --git a/app/api/v1/gen/http/api/client/types.go b/app/api/v1/gen/http/api/client/types.go new file mode 100644 index 0000000..a92300a --- /dev/null +++ b/app/api/v1/gen/http/api/client/types.go @@ -0,0 +1,457 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP client types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + goa "goa.design/goa/v3/pkg" +) + +// CounterIncrementRequestBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP request body. +type CounterIncrementRequestBody struct { + User string `form:"user" json:"user" xml:"user"` +} + +// EchoRequestBody is the type of the "api" service "Echo" endpoint HTTP +// request body. +type EchoRequestBody struct { + Text string `form:"text" json:"text" xml:"text"` +} + +// CounterGetResponseBody is the type of the "api" service "CounterGet" +// endpoint HTTP response body. +type CounterGetResponseBody struct { + Count *int32 `form:"count,omitempty" json:"count,omitempty" xml:"count,omitempty"` + LastIncrementBy *string `form:"last_increment_by,omitempty" json:"last_increment_by,omitempty" xml:"last_increment_by,omitempty"` + LastIncrementAt *string `form:"last_increment_at,omitempty" json:"last_increment_at,omitempty" xml:"last_increment_at,omitempty"` + NextFinalizeAt *string `form:"next_finalize_at,omitempty" json:"next_finalize_at,omitempty" xml:"next_finalize_at,omitempty"` +} + +// CounterIncrementResponseBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP response body. +type CounterIncrementResponseBody struct { + Count *int32 `form:"count,omitempty" json:"count,omitempty" xml:"count,omitempty"` + LastIncrementBy *string `form:"last_increment_by,omitempty" json:"last_increment_by,omitempty" xml:"last_increment_by,omitempty"` + LastIncrementAt *string `form:"last_increment_at,omitempty" json:"last_increment_at,omitempty" xml:"last_increment_at,omitempty"` + NextFinalizeAt *string `form:"next_finalize_at,omitempty" json:"next_finalize_at,omitempty" xml:"next_finalize_at,omitempty"` +} + +// EchoResponseBody is the type of the "api" service "Echo" endpoint HTTP +// response body. +type EchoResponseBody struct { + Text *string `form:"text,omitempty" json:"text,omitempty" xml:"text,omitempty"` +} + +// CounterGetUnauthorizedResponseBody is the type of the "api" service +// "CounterGet" endpoint HTTP response body for the "unauthorized" error. +type CounterGetUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// CounterGetExistingIncrementRequestResponseBody is the type of the "api" +// service "CounterGet" endpoint HTTP response body for the +// "existing_increment_request" error. +type CounterGetExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// CounterIncrementUnauthorizedResponseBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP response body for the "unauthorized" error. +type CounterIncrementUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// CounterIncrementExistingIncrementRequestResponseBody is the type of the +// "api" service "CounterIncrement" endpoint HTTP response body for the +// "existing_increment_request" error. +type CounterIncrementExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// EchoUnauthorizedResponseBody is the type of the "api" service "Echo" +// endpoint HTTP response body for the "unauthorized" error. +type EchoUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// EchoExistingIncrementRequestResponseBody is the type of the "api" service +// "Echo" endpoint HTTP response body for the "existing_increment_request" +// error. +type EchoExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // ID is a unique identifier for this particular occurrence of the problem. + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + // Is the error temporary? + Temporary *bool `form:"temporary,omitempty" json:"temporary,omitempty" xml:"temporary,omitempty"` + // Is the error a timeout? + Timeout *bool `form:"timeout,omitempty" json:"timeout,omitempty" xml:"timeout,omitempty"` + // Is the error a server-side fault? + Fault *bool `form:"fault,omitempty" json:"fault,omitempty" xml:"fault,omitempty"` +} + +// NewCounterIncrementRequestBody builds the HTTP request body from the payload +// of the "CounterIncrement" endpoint of the "api" service. +func NewCounterIncrementRequestBody(p *api.CounterIncrementPayload) *CounterIncrementRequestBody { + body := &CounterIncrementRequestBody{ + User: p.User, + } + return body +} + +// NewEchoRequestBody builds the HTTP request body from the payload of the +// "Echo" endpoint of the "api" service. +func NewEchoRequestBody(p *api.EchoPayload) *EchoRequestBody { + body := &EchoRequestBody{ + Text: p.Text, + } + return body +} + +// NewCounterGetCounterInfoOK builds a "api" service "CounterGet" endpoint +// result from a HTTP "OK" response. +func NewCounterGetCounterInfoOK(body *CounterGetResponseBody) *apiviews.CounterInfoView { + v := &apiviews.CounterInfoView{ + Count: body.Count, + LastIncrementBy: body.LastIncrementBy, + LastIncrementAt: body.LastIncrementAt, + NextFinalizeAt: body.NextFinalizeAt, + } + + return v +} + +// NewCounterGetUnauthorized builds a api service CounterGet endpoint +// unauthorized error. +func NewCounterGetUnauthorized(body *CounterGetUnauthorizedResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewCounterGetExistingIncrementRequest builds a api service CounterGet +// endpoint existing_increment_request error. +func NewCounterGetExistingIncrementRequest(body *CounterGetExistingIncrementRequestResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewCounterIncrementCounterInfoAccepted builds a "api" service +// "CounterIncrement" endpoint result from a HTTP "Accepted" response. +func NewCounterIncrementCounterInfoAccepted(body *CounterIncrementResponseBody) *apiviews.CounterInfoView { + v := &apiviews.CounterInfoView{ + Count: body.Count, + LastIncrementBy: body.LastIncrementBy, + LastIncrementAt: body.LastIncrementAt, + NextFinalizeAt: body.NextFinalizeAt, + } + + return v +} + +// NewCounterIncrementUnauthorized builds a api service CounterIncrement +// endpoint unauthorized error. +func NewCounterIncrementUnauthorized(body *CounterIncrementUnauthorizedResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewCounterIncrementExistingIncrementRequest builds a api service +// CounterIncrement endpoint existing_increment_request error. +func NewCounterIncrementExistingIncrementRequest(body *CounterIncrementExistingIncrementRequestResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewEchoResultOK builds a "api" service "Echo" endpoint result from a HTTP +// "OK" response. +func NewEchoResultOK(body *EchoResponseBody) *api.EchoResult { + v := &api.EchoResult{ + Text: *body.Text, + } + + return v +} + +// NewEchoUnauthorized builds a api service Echo endpoint unauthorized error. +func NewEchoUnauthorized(body *EchoUnauthorizedResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// NewEchoExistingIncrementRequest builds a api service Echo endpoint +// existing_increment_request error. +func NewEchoExistingIncrementRequest(body *EchoExistingIncrementRequestResponseBody) *goa.ServiceError { + v := &goa.ServiceError{ + Name: *body.Name, + ID: *body.ID, + Message: *body.Message, + Temporary: *body.Temporary, + Timeout: *body.Timeout, + Fault: *body.Fault, + } + + return v +} + +// ValidateEchoResponseBody runs the validations defined on EchoResponseBody +func ValidateEchoResponseBody(body *EchoResponseBody) (err error) { + if body.Text == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("text", "body")) + } + return +} + +// ValidateCounterGetUnauthorizedResponseBody runs the validations defined on +// CounterGet_unauthorized_Response_Body +func ValidateCounterGetUnauthorizedResponseBody(body *CounterGetUnauthorizedResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateCounterGetExistingIncrementRequestResponseBody runs the validations +// defined on CounterGet_existing_increment_request_Response_Body +func ValidateCounterGetExistingIncrementRequestResponseBody(body *CounterGetExistingIncrementRequestResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateCounterIncrementUnauthorizedResponseBody runs the validations +// defined on CounterIncrement_unauthorized_Response_Body +func ValidateCounterIncrementUnauthorizedResponseBody(body *CounterIncrementUnauthorizedResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateCounterIncrementExistingIncrementRequestResponseBody runs the +// validations defined on +// CounterIncrement_existing_increment_request_Response_Body +func ValidateCounterIncrementExistingIncrementRequestResponseBody(body *CounterIncrementExistingIncrementRequestResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateEchoUnauthorizedResponseBody runs the validations defined on +// Echo_unauthorized_Response_Body +func ValidateEchoUnauthorizedResponseBody(body *EchoUnauthorizedResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} + +// ValidateEchoExistingIncrementRequestResponseBody runs the validations +// defined on Echo_existing_increment_request_Response_Body +func ValidateEchoExistingIncrementRequestResponseBody(body *EchoExistingIncrementRequestResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Temporary == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("temporary", "body")) + } + if body.Timeout == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("timeout", "body")) + } + if body.Fault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("fault", "body")) + } + return +} diff --git a/app/api/v1/gen/http/api/server/encode_decode.go b/app/api/v1/gen/http/api/server/encode_decode.go new file mode 100644 index 0000000..6e7545a --- /dev/null +++ b/app/api/v1/gen/http/api/server/encode_decode.go @@ -0,0 +1,240 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP server encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + "errors" + "io" + "net/http" + + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// EncodeCounterGetResponse returns an encoder for responses returned by the +// api CounterGet endpoint. +func EncodeCounterGetResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res := v.(*apiviews.CounterInfo) + enc := encoder(ctx, w) + body := NewCounterGetResponseBody(res.Projected) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// EncodeCounterGetError returns an encoder for errors returned by the +// CounterGet api endpoint. +func EncodeCounterGetError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "unauthorized": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewCounterGetUnauthorizedResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusUnauthorized) + return enc.Encode(body) + case "existing_increment_request": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewCounterGetExistingIncrementRequestResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusTooManyRequests) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} + +// EncodeCounterIncrementResponse returns an encoder for responses returned by +// the api CounterIncrement endpoint. +func EncodeCounterIncrementResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res := v.(*apiviews.CounterInfo) + enc := encoder(ctx, w) + body := NewCounterIncrementResponseBody(res.Projected) + w.WriteHeader(http.StatusAccepted) + return enc.Encode(body) + } +} + +// DecodeCounterIncrementRequest returns a decoder for requests sent to the api +// CounterIncrement endpoint. +func DecodeCounterIncrementRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (any, error) { + return func(r *http.Request) (any, error) { + var ( + body CounterIncrementRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if err == io.EOF { + return nil, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return nil, gerr + } + return nil, goa.DecodePayloadError(err.Error()) + } + err = ValidateCounterIncrementRequestBody(&body) + if err != nil { + return nil, err + } + payload := NewCounterIncrementPayload(&body) + + return payload, nil + } +} + +// EncodeCounterIncrementError returns an encoder for errors returned by the +// CounterIncrement api endpoint. +func EncodeCounterIncrementError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "unauthorized": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewCounterIncrementUnauthorizedResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusUnauthorized) + return enc.Encode(body) + case "existing_increment_request": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewCounterIncrementExistingIncrementRequestResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusTooManyRequests) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} + +// EncodeEchoResponse returns an encoder for responses returned by the api Echo +// endpoint. +func EncodeEchoResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*api.EchoResult) + enc := encoder(ctx, w) + body := NewEchoResponseBody(res) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// DecodeEchoRequest returns a decoder for requests sent to the api Echo +// endpoint. +func DecodeEchoRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (any, error) { + return func(r *http.Request) (any, error) { + var ( + body EchoRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if err == io.EOF { + return nil, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return nil, gerr + } + return nil, goa.DecodePayloadError(err.Error()) + } + err = ValidateEchoRequestBody(&body) + if err != nil { + return nil, err + } + payload := NewEchoPayload(&body) + + return payload, nil + } +} + +// EncodeEchoError returns an encoder for errors returned by the Echo api +// endpoint. +func EncodeEchoError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "unauthorized": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewEchoUnauthorizedResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusUnauthorized) + return enc.Encode(body) + case "existing_increment_request": + var res *goa.ServiceError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewEchoExistingIncrementRequestResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusTooManyRequests) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} diff --git a/app/api/v1/gen/http/api/server/paths.go b/app/api/v1/gen/http/api/server/paths.go new file mode 100644 index 0000000..e1aae0e --- /dev/null +++ b/app/api/v1/gen/http/api/server/paths.go @@ -0,0 +1,23 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// HTTP request path constructors for the api service. +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +// CounterGetAPIPath returns the URL path to the api service CounterGet HTTP endpoint. +func CounterGetAPIPath() string { + return "/counter" +} + +// CounterIncrementAPIPath returns the URL path to the api service CounterIncrement HTTP endpoint. +func CounterIncrementAPIPath() string { + return "/counter/inc" +} + +// EchoAPIPath returns the URL path to the api service Echo HTTP endpoint. +func EchoAPIPath() string { + return "/echo" +} diff --git a/app/api/v1/gen/http/api/server/server.go b/app/api/v1/gen/http/api/server/server.go new file mode 100644 index 0000000..bb690b0 --- /dev/null +++ b/app/api/v1/gen/http/api/server/server.go @@ -0,0 +1,272 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP server +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + "net/http" + "path" + + api "github.com/jace-ys/countup/api/v1/gen/api" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// Server lists the api service endpoint HTTP handlers. +type Server struct { + Mounts []*MountPoint + CounterGet http.Handler + CounterIncrement http.Handler + Echo http.Handler + GenHTTPOpenapi3JSON http.Handler +} + +// MountPoint holds information about the mounted endpoints. +type MountPoint struct { + // Method is the name of the service method served by the mounted HTTP handler. + Method string + // Verb is the HTTP method used to match requests to the mounted handler. + Verb string + // Pattern is the HTTP request path pattern used to match requests to the + // mounted handler. + Pattern string +} + +// New instantiates HTTP handlers for all the api service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *api.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemGenHTTPOpenapi3JSON http.FileSystem, +) *Server { + if fileSystemGenHTTPOpenapi3JSON == nil { + fileSystemGenHTTPOpenapi3JSON = http.Dir(".") + } + fileSystemGenHTTPOpenapi3JSON = appendPrefix(fileSystemGenHTTPOpenapi3JSON, "/gen/http") + return &Server{ + Mounts: []*MountPoint{ + {"CounterGet", "GET", "/counter"}, + {"CounterIncrement", "POST", "/counter/inc"}, + {"Echo", "POST", "/echo"}, + {"Serve gen/http/openapi3.json", "GET", "/openapi.json"}, + }, + CounterGet: NewCounterGetHandler(e.CounterGet, mux, decoder, encoder, errhandler, formatter), + CounterIncrement: NewCounterIncrementHandler(e.CounterIncrement, mux, decoder, encoder, errhandler, formatter), + Echo: NewEchoHandler(e.Echo, mux, decoder, encoder, errhandler, formatter), + GenHTTPOpenapi3JSON: http.FileServer(fileSystemGenHTTPOpenapi3JSON), + } +} + +// Service returns the name of the service served. +func (s *Server) Service() string { return "api" } + +// Use wraps the server handlers with the given middleware. +func (s *Server) Use(m func(http.Handler) http.Handler) { + s.CounterGet = m(s.CounterGet) + s.CounterIncrement = m(s.CounterIncrement) + s.Echo = m(s.Echo) +} + +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return api.MethodNames[:] } + +// Mount configures the mux to serve the api endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountCounterGetHandler(mux, h.CounterGet) + MountCounterIncrementHandler(mux, h.CounterIncrement) + MountEchoHandler(mux, h.Echo) + MountGenHTTPOpenapi3JSON(mux, h.GenHTTPOpenapi3JSON) +} + +// Mount configures the mux to serve the api endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} + +// MountCounterGetHandler configures the mux to serve the "api" service +// "CounterGet" endpoint. +func MountCounterGetHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/counter", f) +} + +// NewCounterGetHandler creates a HTTP handler which loads the HTTP request and +// calls the "api" service "CounterGet" endpoint. +func NewCounterGetHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + encodeResponse = EncodeCounterGetResponse(encoder) + encodeError = EncodeCounterGetError(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "CounterGet") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + var err error + res, err := endpoint(ctx, nil) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + +// MountCounterIncrementHandler configures the mux to serve the "api" service +// "CounterIncrement" endpoint. +func MountCounterIncrementHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("POST", "/counter/inc", f) +} + +// NewCounterIncrementHandler creates a HTTP handler which loads the HTTP +// request and calls the "api" service "CounterIncrement" endpoint. +func NewCounterIncrementHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeCounterIncrementRequest(mux, decoder) + encodeResponse = EncodeCounterIncrementResponse(encoder) + encodeError = EncodeCounterIncrementError(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "CounterIncrement") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + +// MountEchoHandler configures the mux to serve the "api" service "Echo" +// endpoint. +func MountEchoHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("POST", "/echo", f) +} + +// NewEchoHandler creates a HTTP handler which loads the HTTP request and calls +// the "api" service "Echo" endpoint. +func NewEchoHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeEchoRequest(mux, decoder) + encodeResponse = EncodeEchoResponse(encoder) + encodeError = EncodeEchoError(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "Echo") + ctx = context.WithValue(ctx, goa.ServiceKey, "api") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + +// appendFS is a custom implementation of fs.FS that appends a specified prefix +// to the file paths before delegating the Open call to the underlying fs.FS. +type appendFS struct { + prefix string + fs http.FileSystem +} + +// Open opens the named file, appending the prefix to the file path before +// passing it to the underlying fs.FS. +func (s appendFS) Open(name string) (http.File, error) { + switch name { + case "/openapi.json": + name = "/openapi3.json" + } + return s.fs.Open(path.Join(s.prefix, name)) +} + +// appendPrefix returns a new fs.FS that appends the specified prefix to file paths +// before delegating to the provided embed.FS. +func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { + return appendFS{prefix: prefix, fs: fsys} +} + +// MountGenHTTPOpenapi3JSON configures the mux to serve GET request made to +// "/openapi.json". +func MountGenHTTPOpenapi3JSON(mux goahttp.Muxer, h http.Handler) { + mux.Handle("GET", "/openapi.json", h.ServeHTTP) +} diff --git a/app/api/v1/gen/http/api/server/types.go b/app/api/v1/gen/http/api/server/types.go new file mode 100644 index 0000000..c517257 --- /dev/null +++ b/app/api/v1/gen/http/api/server/types.go @@ -0,0 +1,315 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// api HTTP server types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + api "github.com/jace-ys/countup/api/v1/gen/api" + apiviews "github.com/jace-ys/countup/api/v1/gen/api/views" + goa "goa.design/goa/v3/pkg" +) + +// CounterIncrementRequestBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP request body. +type CounterIncrementRequestBody struct { + User *string `form:"user,omitempty" json:"user,omitempty" xml:"user,omitempty"` +} + +// EchoRequestBody is the type of the "api" service "Echo" endpoint HTTP +// request body. +type EchoRequestBody struct { + Text *string `form:"text,omitempty" json:"text,omitempty" xml:"text,omitempty"` +} + +// CounterGetResponseBody is the type of the "api" service "CounterGet" +// endpoint HTTP response body. +type CounterGetResponseBody struct { + Count int32 `form:"count" json:"count" xml:"count"` + LastIncrementBy string `form:"last_increment_by" json:"last_increment_by" xml:"last_increment_by"` + LastIncrementAt string `form:"last_increment_at" json:"last_increment_at" xml:"last_increment_at"` + NextFinalizeAt string `form:"next_finalize_at" json:"next_finalize_at" xml:"next_finalize_at"` +} + +// CounterIncrementResponseBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP response body. +type CounterIncrementResponseBody struct { + Count int32 `form:"count" json:"count" xml:"count"` + LastIncrementBy string `form:"last_increment_by" json:"last_increment_by" xml:"last_increment_by"` + LastIncrementAt string `form:"last_increment_at" json:"last_increment_at" xml:"last_increment_at"` + NextFinalizeAt string `form:"next_finalize_at" json:"next_finalize_at" xml:"next_finalize_at"` +} + +// EchoResponseBody is the type of the "api" service "Echo" endpoint HTTP +// response body. +type EchoResponseBody struct { + Text string `form:"text" json:"text" xml:"text"` +} + +// CounterGetUnauthorizedResponseBody is the type of the "api" service +// "CounterGet" endpoint HTTP response body for the "unauthorized" error. +type CounterGetUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// CounterGetExistingIncrementRequestResponseBody is the type of the "api" +// service "CounterGet" endpoint HTTP response body for the +// "existing_increment_request" error. +type CounterGetExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// CounterIncrementUnauthorizedResponseBody is the type of the "api" service +// "CounterIncrement" endpoint HTTP response body for the "unauthorized" error. +type CounterIncrementUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// CounterIncrementExistingIncrementRequestResponseBody is the type of the +// "api" service "CounterIncrement" endpoint HTTP response body for the +// "existing_increment_request" error. +type CounterIncrementExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// EchoUnauthorizedResponseBody is the type of the "api" service "Echo" +// endpoint HTTP response body for the "unauthorized" error. +type EchoUnauthorizedResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// EchoExistingIncrementRequestResponseBody is the type of the "api" service +// "Echo" endpoint HTTP response body for the "existing_increment_request" +// error. +type EchoExistingIncrementRequestResponseBody struct { + // Name is the name of this class of errors. + Name string `form:"name" json:"name" xml:"name"` + // ID is a unique identifier for this particular occurrence of the problem. + ID string `form:"id" json:"id" xml:"id"` + // Message is a human-readable explanation specific to this occurrence of the + // problem. + Message string `form:"message" json:"message" xml:"message"` + // Is the error temporary? + Temporary bool `form:"temporary" json:"temporary" xml:"temporary"` + // Is the error a timeout? + Timeout bool `form:"timeout" json:"timeout" xml:"timeout"` + // Is the error a server-side fault? + Fault bool `form:"fault" json:"fault" xml:"fault"` +} + +// NewCounterGetResponseBody builds the HTTP response body from the result of +// the "CounterGet" endpoint of the "api" service. +func NewCounterGetResponseBody(res *apiviews.CounterInfoView) *CounterGetResponseBody { + body := &CounterGetResponseBody{ + Count: *res.Count, + LastIncrementBy: *res.LastIncrementBy, + LastIncrementAt: *res.LastIncrementAt, + NextFinalizeAt: *res.NextFinalizeAt, + } + return body +} + +// NewCounterIncrementResponseBody builds the HTTP response body from the +// result of the "CounterIncrement" endpoint of the "api" service. +func NewCounterIncrementResponseBody(res *apiviews.CounterInfoView) *CounterIncrementResponseBody { + body := &CounterIncrementResponseBody{ + Count: *res.Count, + LastIncrementBy: *res.LastIncrementBy, + LastIncrementAt: *res.LastIncrementAt, + NextFinalizeAt: *res.NextFinalizeAt, + } + return body +} + +// NewEchoResponseBody builds the HTTP response body from the result of the +// "Echo" endpoint of the "api" service. +func NewEchoResponseBody(res *api.EchoResult) *EchoResponseBody { + body := &EchoResponseBody{ + Text: res.Text, + } + return body +} + +// NewCounterGetUnauthorizedResponseBody builds the HTTP response body from the +// result of the "CounterGet" endpoint of the "api" service. +func NewCounterGetUnauthorizedResponseBody(res *goa.ServiceError) *CounterGetUnauthorizedResponseBody { + body := &CounterGetUnauthorizedResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewCounterGetExistingIncrementRequestResponseBody builds the HTTP response +// body from the result of the "CounterGet" endpoint of the "api" service. +func NewCounterGetExistingIncrementRequestResponseBody(res *goa.ServiceError) *CounterGetExistingIncrementRequestResponseBody { + body := &CounterGetExistingIncrementRequestResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewCounterIncrementUnauthorizedResponseBody builds the HTTP response body +// from the result of the "CounterIncrement" endpoint of the "api" service. +func NewCounterIncrementUnauthorizedResponseBody(res *goa.ServiceError) *CounterIncrementUnauthorizedResponseBody { + body := &CounterIncrementUnauthorizedResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewCounterIncrementExistingIncrementRequestResponseBody builds the HTTP +// response body from the result of the "CounterIncrement" endpoint of the +// "api" service. +func NewCounterIncrementExistingIncrementRequestResponseBody(res *goa.ServiceError) *CounterIncrementExistingIncrementRequestResponseBody { + body := &CounterIncrementExistingIncrementRequestResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewEchoUnauthorizedResponseBody builds the HTTP response body from the +// result of the "Echo" endpoint of the "api" service. +func NewEchoUnauthorizedResponseBody(res *goa.ServiceError) *EchoUnauthorizedResponseBody { + body := &EchoUnauthorizedResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewEchoExistingIncrementRequestResponseBody builds the HTTP response body +// from the result of the "Echo" endpoint of the "api" service. +func NewEchoExistingIncrementRequestResponseBody(res *goa.ServiceError) *EchoExistingIncrementRequestResponseBody { + body := &EchoExistingIncrementRequestResponseBody{ + Name: res.Name, + ID: res.ID, + Message: res.Message, + Temporary: res.Temporary, + Timeout: res.Timeout, + Fault: res.Fault, + } + return body +} + +// NewCounterIncrementPayload builds a api service CounterIncrement endpoint +// payload. +func NewCounterIncrementPayload(body *CounterIncrementRequestBody) *api.CounterIncrementPayload { + v := &api.CounterIncrementPayload{ + User: *body.User, + } + + return v +} + +// NewEchoPayload builds a api service Echo endpoint payload. +func NewEchoPayload(body *EchoRequestBody) *api.EchoPayload { + v := &api.EchoPayload{ + Text: *body.Text, + } + + return v +} + +// ValidateCounterIncrementRequestBody runs the validations defined on +// CounterIncrementRequestBody +func ValidateCounterIncrementRequestBody(body *CounterIncrementRequestBody) (err error) { + if body.User == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("user", "body")) + } + return +} + +// ValidateEchoRequestBody runs the validations defined on EchoRequestBody +func ValidateEchoRequestBody(body *EchoRequestBody) (err error) { + if body.Text == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("text", "body")) + } + return +} diff --git a/app/api/v1/gen/http/cli/countup/cli.go b/app/api/v1/gen/http/cli/countup/cli.go new file mode 100644 index 0000000..fb50aaa --- /dev/null +++ b/app/api/v1/gen/http/cli/countup/cli.go @@ -0,0 +1,262 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// countup HTTP client CLI support package +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package cli + +import ( + "flag" + "fmt" + "net/http" + "os" + + apic "github.com/jace-ys/countup/api/v1/gen/http/api/client" + webc "github.com/jace-ys/countup/api/v1/gen/http/web/client" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// UsageCommands returns the set of commands and sub-commands using the format +// +// command (subcommand1|subcommand2|...) +func UsageCommands() string { + return `api (counter-get|counter-increment|echo) +web (index|another) +` +} + +// UsageExamples produces an example of a valid invocation of the CLI tool. +func UsageExamples() string { + return os.Args[0] + ` api counter-get` + "\n" + + os.Args[0] + ` web index` + "\n" + + "" +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, +) (goa.Endpoint, any, error) { + var ( + apiFlags = flag.NewFlagSet("api", flag.ContinueOnError) + + apiCounterGetFlags = flag.NewFlagSet("counter-get", flag.ExitOnError) + + apiCounterIncrementFlags = flag.NewFlagSet("counter-increment", flag.ExitOnError) + apiCounterIncrementBodyFlag = apiCounterIncrementFlags.String("body", "REQUIRED", "") + + apiEchoFlags = flag.NewFlagSet("echo", flag.ExitOnError) + apiEchoBodyFlag = apiEchoFlags.String("body", "REQUIRED", "") + + webFlags = flag.NewFlagSet("web", flag.ContinueOnError) + + webIndexFlags = flag.NewFlagSet("index", flag.ExitOnError) + + webAnotherFlags = flag.NewFlagSet("another", flag.ExitOnError) + ) + apiFlags.Usage = apiUsage + apiCounterGetFlags.Usage = apiCounterGetUsage + apiCounterIncrementFlags.Usage = apiCounterIncrementUsage + apiEchoFlags.Usage = apiEchoUsage + + webFlags.Usage = webUsage + webIndexFlags.Usage = webIndexUsage + webAnotherFlags.Usage = webAnotherUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "api": + svcf = apiFlags + case "web": + svcf = webFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "api": + switch epn { + case "counter-get": + epf = apiCounterGetFlags + + case "counter-increment": + epf = apiCounterIncrementFlags + + case "echo": + epf = apiEchoFlags + + } + + case "web": + switch epn { + case "index": + epf = webIndexFlags + + case "another": + epf = webAnotherFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "api": + c := apic.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "counter-get": + endpoint = c.CounterGet() + case "counter-increment": + endpoint = c.CounterIncrement() + data, err = apic.BuildCounterIncrementPayload(*apiCounterIncrementBodyFlag) + case "echo": + endpoint = c.Echo() + data, err = apic.BuildEchoPayload(*apiEchoBodyFlag) + } + case "web": + c := webc.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "index": + endpoint = c.Index() + case "another": + endpoint = c.Another() + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +// apiUsage displays the usage of the api command and its subcommands. +func apiUsage() { + fmt.Fprintf(os.Stderr, `Service is the api service interface. +Usage: + %[1]s [globalflags] api COMMAND [flags] + +COMMAND: + counter-get: CounterGet implements CounterGet. + counter-increment: CounterIncrement implements CounterIncrement. + echo: Echo implements Echo. + +Additional help: + %[1]s api COMMAND --help +`, os.Args[0]) +} +func apiCounterGetUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api counter-get + +CounterGet implements CounterGet. + +Example: + %[1]s api counter-get +`, os.Args[0]) +} + +func apiCounterIncrementUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api counter-increment -body JSON + +CounterIncrement implements CounterIncrement. + -body JSON: + +Example: + %[1]s api counter-increment --body '{ + "user": "Nihil doloribus et sed sequi consequatur." + }' +`, os.Args[0]) +} + +func apiEchoUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] api echo -body JSON + +Echo implements Echo. + -body JSON: + +Example: + %[1]s api echo --body '{ + "text": "Vel omnis quo sit." + }' +`, os.Args[0]) +} + +// webUsage displays the usage of the web command and its subcommands. +func webUsage() { + fmt.Fprintf(os.Stderr, `Service is the web service interface. +Usage: + %[1]s [globalflags] web COMMAND [flags] + +COMMAND: + index: Index implements index. + another: Another implements another. + +Additional help: + %[1]s web COMMAND --help +`, os.Args[0]) +} +func webIndexUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] web index + +Index implements index. + +Example: + %[1]s web index +`, os.Args[0]) +} + +func webAnotherUsage() { + fmt.Fprintf(os.Stderr, `%[1]s [flags] web another + +Another implements another. + +Example: + %[1]s web another +`, os.Args[0]) +} diff --git a/app/api/v1/gen/http/openapi.json b/app/api/v1/gen/http/openapi.json new file mode 100644 index 0000000..3cfe53a --- /dev/null +++ b/app/api/v1/gen/http/openapi.json @@ -0,0 +1 @@ +{"swagger":"2.0","info":{"title":"Count Up","description":"A production-ready Go service deployed on Kubernetes","version":"1.0.0"},"host":"localhost:8080","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/":{"get":{"tags":["web"],"summary":"index web","operationId":"web#index","produces":["text/html"],"responses":{"200":{"description":"OK response.","schema":{"type":"string","format":"byte"}}},"schemes":["http"]}},"/another":{"get":{"tags":["web"],"summary":"another web","operationId":"web#another","produces":["text/html"],"responses":{"200":{"description":"OK response.","schema":{"type":"string","format":"byte"}}},"schemes":["http"]}},"/counter":{"get":{"tags":["api"],"summary":"CounterGet api","operationId":"api#CounterGet","responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/CounterInfo"}},"401":{"description":"Unauthorized response.","schema":{"$ref":"#/definitions/APICounterGetUnauthorizedResponseBody"}},"429":{"description":"Too Many Requests response.","schema":{"$ref":"#/definitions/APICounterGetExistingIncrementRequestResponseBody"}}},"schemes":["http"]}},"/counter/inc":{"post":{"tags":["api"],"summary":"CounterIncrement api","operationId":"api#CounterIncrement","parameters":[{"name":"CounterIncrementRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/APICounterIncrementRequestBody","required":["user"]}}],"responses":{"202":{"description":"Accepted response.","schema":{"$ref":"#/definitions/CounterInfo"}},"401":{"description":"Unauthorized response.","schema":{"$ref":"#/definitions/APICounterIncrementUnauthorizedResponseBody"}},"429":{"description":"Too Many Requests response.","schema":{"$ref":"#/definitions/APICounterIncrementExistingIncrementRequestResponseBody"}}},"schemes":["http"]}},"/echo":{"post":{"tags":["api"],"summary":"Echo api","operationId":"api#Echo","parameters":[{"name":"EchoRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/APIEchoRequestBody","required":["text"]}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/APIEchoResponseBody","required":["text"]}},"401":{"description":"Unauthorized response.","schema":{"$ref":"#/definitions/APIEchoUnauthorizedResponseBody"}},"429":{"description":"Too Many Requests response.","schema":{"$ref":"#/definitions/APIEchoExistingIncrementRequestResponseBody"}}},"schemes":["http"]}},"/openapi.json":{"get":{"tags":["api"],"summary":"Download gen/http/openapi3.json","operationId":"api#/openapi.json","responses":{"200":{"description":"File downloaded","schema":{"type":"file"}}},"schemes":["http"]}},"/static/{path}":{"get":{"tags":["web"],"summary":"Download static/","operationId":"web#/static/{*path}","parameters":[{"name":"path","in":"path","description":"Relative file path","required":true,"type":"string"}],"responses":{"200":{"description":"File downloaded","schema":{"type":"file"}},"404":{"description":"File not found","schema":{"$ref":"#/definitions/Error"}}},"schemes":["http"]}}},"definitions":{"APICounterGetExistingIncrementRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"CounterGet_existing_increment_request_Response_Body result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"APICounterGetUnauthorizedResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"CounterGet_unauthorized_Response_Body result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"APICounterIncrementExistingIncrementRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"CounterIncrement_existing_increment_request_Response_Body result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"APICounterIncrementRequestBody":{"title":"APICounterIncrementRequestBody","type":"object","properties":{"user":{"type":"string","example":"Non perspiciatis eum dicta sit."}},"example":{"user":"Odio tenetur temporibus."},"required":["user"]},"APICounterIncrementUnauthorizedResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":true}},"description":"CounterIncrement_unauthorized_Response_Body result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"APIEchoExistingIncrementRequestResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Echo_existing_increment_request_Response_Body result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]},"APIEchoRequestBody":{"title":"APIEchoRequestBody","type":"object","properties":{"text":{"type":"string","example":"Deleniti necessitatibus numquam perspiciatis quia ipsa quam."}},"example":{"text":"Nemo dolorem ullam magnam."},"required":["text"]},"APIEchoResponseBody":{"title":"APIEchoResponseBody","type":"object","properties":{"text":{"type":"string","example":"Praesentium qui debitis."}},"example":{"text":"Odit cum blanditiis ut."},"required":["text"]},"APIEchoUnauthorizedResponseBody":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":false},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Echo_unauthorized_Response_Body result type (default view)","example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":false,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]},"CounterInfo":{"title":"Mediatype identifier: application/vnd.countup.counter-info`; view=default","type":"object","properties":{"count":{"type":"integer","example":1315490442,"format":"int32"},"last_increment_at":{"type":"string","example":"Eligendi quisquam."},"last_increment_by":{"type":"string","example":"Vero molestiae."},"next_finalize_at":{"type":"string","example":"Aut in dolor eum consequatur."}},"description":"CounterGetResponseBody result type (default view)","example":{"count":1162746042,"last_increment_at":"Tempore asperiores.","last_increment_by":"Tempora repellendus.","next_finalize_at":"Quae voluptatibus dolor fugit quia."},"required":["count","last_increment_by","last_increment_at","next_finalize_at"]},"Error":{"title":"Mediatype identifier: application/vnd.goa.error; view=default","type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":false},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"description":"Error response result type (default view)","example":{"fault":true,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":true},"required":["name","id","message","temporary","timeout","fault"]}}} \ No newline at end of file diff --git a/app/api/v1/gen/http/openapi.yaml b/app/api/v1/gen/http/openapi.yaml new file mode 100644 index 0000000..9ccd634 --- /dev/null +++ b/app/api/v1/gen/http/openapi.yaml @@ -0,0 +1,527 @@ +swagger: "2.0" +info: + title: Count Up + description: A production-ready Go service deployed on Kubernetes + version: 1.0.0 +host: localhost:8080 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /: + get: + tags: + - web + summary: index web + operationId: web#index + produces: + - text/html + responses: + "200": + description: OK response. + schema: + type: string + format: byte + schemes: + - http + /another: + get: + tags: + - web + summary: another web + operationId: web#another + produces: + - text/html + responses: + "200": + description: OK response. + schema: + type: string + format: byte + schemes: + - http + /counter: + get: + tags: + - api + summary: CounterGet api + operationId: api#CounterGet + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/CounterInfo' + "401": + description: Unauthorized response. + schema: + $ref: '#/definitions/APICounterGetUnauthorizedResponseBody' + "429": + description: Too Many Requests response. + schema: + $ref: '#/definitions/APICounterGetExistingIncrementRequestResponseBody' + schemes: + - http + /counter/inc: + post: + tags: + - api + summary: CounterIncrement api + operationId: api#CounterIncrement + parameters: + - name: CounterIncrementRequestBody + in: body + required: true + schema: + $ref: '#/definitions/APICounterIncrementRequestBody' + required: + - user + responses: + "202": + description: Accepted response. + schema: + $ref: '#/definitions/CounterInfo' + "401": + description: Unauthorized response. + schema: + $ref: '#/definitions/APICounterIncrementUnauthorizedResponseBody' + "429": + description: Too Many Requests response. + schema: + $ref: '#/definitions/APICounterIncrementExistingIncrementRequestResponseBody' + schemes: + - http + /echo: + post: + tags: + - api + summary: Echo api + operationId: api#Echo + parameters: + - name: EchoRequestBody + in: body + required: true + schema: + $ref: '#/definitions/APIEchoRequestBody' + required: + - text + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/APIEchoResponseBody' + required: + - text + "401": + description: Unauthorized response. + schema: + $ref: '#/definitions/APIEchoUnauthorizedResponseBody' + "429": + description: Too Many Requests response. + schema: + $ref: '#/definitions/APIEchoExistingIncrementRequestResponseBody' + schemes: + - http + /openapi.json: + get: + tags: + - api + summary: Download gen/http/openapi3.json + operationId: api#/openapi.json + responses: + "200": + description: File downloaded + schema: + type: file + schemes: + - http + /static/{path}: + get: + tags: + - web + summary: Download static/ + operationId: web#/static/{*path} + parameters: + - name: path + in: path + description: Relative file path + required: true + type: string + responses: + "200": + description: File downloaded + schema: + type: file + "404": + description: File not found + schema: + $ref: '#/definitions/Error' + schemes: + - http +definitions: + APICounterGetExistingIncrementRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: CounterGet_existing_increment_request_Response_Body result type (default view) + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + APICounterGetUnauthorizedResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: CounterGet_unauthorized_Response_Body result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + APICounterIncrementExistingIncrementRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: CounterIncrement_existing_increment_request_Response_Body result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + APICounterIncrementRequestBody: + title: APICounterIncrementRequestBody + type: object + properties: + user: + type: string + example: Non perspiciatis eum dicta sit. + example: + user: Odio tenetur temporibus. + required: + - user + APICounterIncrementUnauthorizedResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: CounterIncrement_unauthorized_Response_Body result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + APIEchoExistingIncrementRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Echo_existing_increment_request_Response_Body result type (default view) + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + APIEchoRequestBody: + title: APIEchoRequestBody + type: object + properties: + text: + type: string + example: Deleniti necessitatibus numquam perspiciatis quia ipsa quam. + example: + text: Nemo dolorem ullam magnam. + required: + - text + APIEchoResponseBody: + title: APIEchoResponseBody + type: object + properties: + text: + type: string + example: Praesentium qui debitis. + example: + text: Odit cum blanditiis ut. + required: + - text + APIEchoUnauthorizedResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Echo_unauthorized_Response_Body result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + CounterInfo: + title: 'Mediatype identifier: application/vnd.countup.counter-info`; view=default' + type: object + properties: + count: + type: integer + example: 1315490442 + format: int32 + last_increment_at: + type: string + example: Eligendi quisquam. + last_increment_by: + type: string + example: Vero molestiae. + next_finalize_at: + type: string + example: Aut in dolor eum consequatur. + description: CounterGetResponseBody result type (default view) + example: + count: 1162746042 + last_increment_at: Tempore asperiores. + last_increment_by: Tempora repellendus. + next_finalize_at: Quae voluptatibus dolor fugit quia. + required: + - count + - last_increment_by + - last_increment_at + - next_finalize_at + Error: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault diff --git a/app/api/v1/gen/http/openapi3.json b/app/api/v1/gen/http/openapi3.json new file mode 100644 index 0000000..0787a4c --- /dev/null +++ b/app/api/v1/gen/http/openapi3.json @@ -0,0 +1 @@ +{"openapi":"3.0.3","info":{"title":"Count Up","description":"A production-ready Go service deployed on Kubernetes","version":"1.0.0"},"servers":[{"url":"http://localhost:8080"},{"url":"http://localhost:80"}],"paths":{"/":{"get":{"tags":["web"],"summary":"index web","operationId":"web#index","responses":{"200":{"description":"OK response.","content":{"text/html":{"schema":{"type":"string","example":"TmVtbyBtYWduaSBkdWNpbXVzIGltcGVkaXQu","format":"binary"},"example":"TW9sbGl0aWEgZGVsZW5pdGkgZXhwZWRpdGEgYWNjdXNhbnRpdW0u"}}}}}},"/another":{"get":{"tags":["web"],"summary":"another web","operationId":"web#another","responses":{"200":{"description":"OK response.","content":{"text/html":{"schema":{"type":"string","example":"TnVtcXVhbSBkb2xvcmlidXMgc3VzY2lwaXQu","format":"binary"},"example":"SW52ZW50b3JlIGxhYm9yZSBlcnJvciBhbmltaS4="}}}}}},"/counter":{"get":{"tags":["api"],"summary":"CounterGet api","operationId":"api#CounterGet","responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CounterInfo"},"example":{"count":1425094436,"last_increment_at":"Iste et distinctio accusantium.","last_increment_by":"Sequi ipsa aliquam esse.","next_finalize_at":"A non."}}}},"401":{"description":"unauthorized: Unauthorized response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"existing_increment_request: Too Many Requests response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/counter/inc":{"post":{"tags":["api"],"summary":"CounterIncrement api","operationId":"api#CounterIncrement","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CounterIncrementRequestBody"},"example":{"user":"Nihil doloribus et sed sequi consequatur."}}}},"responses":{"202":{"description":"Accepted response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CounterInfo"},"example":{"count":278214526,"last_increment_at":"Inventore accusantium.","last_increment_by":"Laborum vel mollitia aut.","next_finalize_at":"Voluptas ut eius."}}}},"401":{"description":"unauthorized: Unauthorized response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"existing_increment_request: Too Many Requests response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/echo":{"post":{"tags":["api"],"summary":"Echo api","operationId":"api#Echo","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EchoRequestBody"},"example":{"text":"Vel omnis quo sit."}}}},"responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EchoRequestBody"},"example":{"text":"Distinctio illo."}}}},"401":{"description":"unauthorized: Unauthorized response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"existing_increment_request: Too Many Requests response.","content":{"application/vnd.goa.error":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/openapi.json":{"get":{"tags":["api"],"summary":"Download gen/http/openapi3.json","operationId":"api#/openapi.json","responses":{"200":{"description":"File downloaded"}}}},"/static/{*path}":{"get":{"tags":["web"],"summary":"Download static/","operationId":"web#/static/{*path}","parameters":[{"name":"path","in":"path","description":"Relative file path","required":true}],"responses":{"200":{"description":"File not found"},"404":{"description":"File not found"}}}}},"components":{"schemas":{"CounterIncrementRequestBody":{"type":"object","properties":{"user":{"type":"string","example":"Omnis debitis."}},"example":{"user":"Eos minima dolorem id sunt voluptates voluptas."},"required":["user"]},"CounterInfo":{"type":"object","properties":{"count":{"type":"integer","example":495281550,"format":"int32"},"last_increment_at":{"type":"string","example":"Aut tenetur eos."},"last_increment_by":{"type":"string","example":"Consectetur odio."},"next_finalize_at":{"type":"string","example":"Laborum et veniam et illum quaerat et."}},"example":{"count":1773148578,"last_increment_at":"Voluptatum omnis possimus saepe deleniti.","last_increment_by":"Corporis est sunt voluptatem reprehenderit neque modi.","next_finalize_at":"Rerum facere veritatis."},"required":["count","last_increment_by","last_increment_at","next_finalize_at"]},"EchoRequestBody":{"type":"object","properties":{"text":{"type":"string","example":"Cumque maxime dolore hic laboriosam."}},"example":{"text":"Aut recusandae cum."},"required":["text"]},"Error":{"type":"object","properties":{"fault":{"type":"boolean","description":"Is the error a server-side fault?","example":true},"id":{"type":"string","description":"ID is a unique identifier for this particular occurrence of the problem.","example":"123abc"},"message":{"type":"string","description":"Message is a human-readable explanation specific to this occurrence of the problem.","example":"parameter 'p' must be an integer"},"name":{"type":"string","description":"Name is the name of this class of errors.","example":"bad_request"},"temporary":{"type":"boolean","description":"Is the error temporary?","example":true},"timeout":{"type":"boolean","description":"Is the error a timeout?","example":false}},"example":{"fault":false,"id":"123abc","message":"parameter 'p' must be an integer","name":"bad_request","temporary":true,"timeout":false},"required":["name","id","message","temporary","timeout","fault"]}}},"tags":[{"name":"api"},{"name":"web"}]} \ No newline at end of file diff --git a/app/api/v1/gen/http/openapi3.yaml b/app/api/v1/gen/http/openapi3.yaml new file mode 100644 index 0000000..659f61e --- /dev/null +++ b/app/api/v1/gen/http/openapi3.yaml @@ -0,0 +1,383 @@ +openapi: 3.0.3 +info: + title: Count Up + description: A production-ready Go service deployed on Kubernetes + version: 1.0.0 +servers: + - url: http://localhost:8080 + - url: http://localhost:80 +paths: + /: + get: + tags: + - web + summary: index web + operationId: web#index + responses: + "200": + description: OK response. + content: + text/html: + schema: + type: string + example: + - 78 + - 101 + - 109 + - 111 + - 32 + - 109 + - 97 + - 103 + - 110 + - 105 + - 32 + - 100 + - 117 + - 99 + - 105 + - 109 + - 117 + - 115 + - 32 + - 105 + - 109 + - 112 + - 101 + - 100 + - 105 + - 116 + - 46 + format: binary + example: + - 77 + - 111 + - 108 + - 108 + - 105 + - 116 + - 105 + - 97 + - 32 + - 100 + - 101 + - 108 + - 101 + - 110 + - 105 + - 116 + - 105 + - 32 + - 101 + - 120 + - 112 + - 101 + - 100 + - 105 + - 116 + - 97 + - 32 + - 97 + - 99 + - 99 + - 117 + - 115 + - 97 + - 110 + - 116 + - 105 + - 117 + - 109 + - 46 + /another: + get: + tags: + - web + summary: another web + operationId: web#another + responses: + "200": + description: OK response. + content: + text/html: + schema: + type: string + example: + - 78 + - 117 + - 109 + - 113 + - 117 + - 97 + - 109 + - 32 + - 100 + - 111 + - 108 + - 111 + - 114 + - 105 + - 98 + - 117 + - 115 + - 32 + - 115 + - 117 + - 115 + - 99 + - 105 + - 112 + - 105 + - 116 + - 46 + format: binary + example: + - 73 + - 110 + - 118 + - 101 + - 110 + - 116 + - 111 + - 114 + - 101 + - 32 + - 108 + - 97 + - 98 + - 111 + - 114 + - 101 + - 32 + - 101 + - 114 + - 114 + - 111 + - 114 + - 32 + - 97 + - 110 + - 105 + - 109 + - 105 + - 46 + /counter: + get: + tags: + - api + summary: CounterGet api + operationId: api#CounterGet + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/CounterInfo' + example: + count: 1425094436 + last_increment_at: Iste et distinctio accusantium. + last_increment_by: Sequi ipsa aliquam esse. + next_finalize_at: A non. + "401": + description: 'unauthorized: Unauthorized response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + "429": + description: 'existing_increment_request: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + /counter/inc: + post: + tags: + - api + summary: CounterIncrement api + operationId: api#CounterIncrement + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CounterIncrementRequestBody' + example: + user: Nihil doloribus et sed sequi consequatur. + responses: + "202": + description: Accepted response. + content: + application/json: + schema: + $ref: '#/components/schemas/CounterInfo' + example: + count: 278214526 + last_increment_at: Inventore accusantium. + last_increment_by: Laborum vel mollitia aut. + next_finalize_at: Voluptas ut eius. + "401": + description: 'unauthorized: Unauthorized response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + "429": + description: 'existing_increment_request: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + /echo: + post: + tags: + - api + summary: Echo api + operationId: api#Echo + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EchoRequestBody' + example: + text: Vel omnis quo sit. + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/EchoRequestBody' + example: + text: Distinctio illo. + "401": + description: 'unauthorized: Unauthorized response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + "429": + description: 'existing_increment_request: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + /openapi.json: + get: + tags: + - api + summary: Download gen/http/openapi3.json + operationId: api#/openapi.json + responses: + "200": + description: File downloaded + /static/{*path}: + get: + tags: + - web + summary: Download static/ + operationId: web#/static/{*path} + parameters: + - name: path + in: path + description: Relative file path + required: true + responses: + "200": + description: File not found + "404": + description: File not found +components: + schemas: + CounterIncrementRequestBody: + type: object + properties: + user: + type: string + example: Omnis debitis. + example: + user: Eos minima dolorem id sunt voluptates voluptas. + required: + - user + CounterInfo: + type: object + properties: + count: + type: integer + example: 495281550 + format: int32 + last_increment_at: + type: string + example: Aut tenetur eos. + last_increment_by: + type: string + example: Consectetur odio. + next_finalize_at: + type: string + example: Laborum et veniam et illum quaerat et. + example: + count: 1773148578 + last_increment_at: Voluptatum omnis possimus saepe deleniti. + last_increment_by: Corporis est sunt voluptatem reprehenderit neque modi. + next_finalize_at: Rerum facere veritatis. + required: + - count + - last_increment_by + - last_increment_at + - next_finalize_at + EchoRequestBody: + type: object + properties: + text: + type: string + example: Cumque maxime dolore hic laboriosam. + example: + text: Aut recusandae cum. + required: + - text + Error: + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: true + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault +tags: + - name: api + - name: web diff --git a/app/api/v1/gen/http/web/client/cli.go b/app/api/v1/gen/http/web/client/cli.go new file mode 100644 index 0000000..fafe96a --- /dev/null +++ b/app/api/v1/gen/http/web/client/cli.go @@ -0,0 +1,8 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP client CLI support package +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client diff --git a/app/api/v1/gen/http/web/client/client.go b/app/api/v1/gen/http/web/client/client.go new file mode 100644 index 0000000..df4b614 --- /dev/null +++ b/app/api/v1/gen/http/web/client/client.go @@ -0,0 +1,93 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web client HTTP transport +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "context" + "net/http" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// Client lists the web service endpoint HTTP clients. +type Client struct { + // Index Doer is the HTTP client used to make requests to the index endpoint. + IndexDoer goahttp.Doer + + // Another Doer is the HTTP client used to make requests to the another + // endpoint. + AnotherDoer goahttp.Doer + + // RestoreResponseBody controls whether the response bodies are reset after + // decoding so they can be read again. + RestoreResponseBody bool + + scheme string + host string + encoder func(*http.Request) goahttp.Encoder + decoder func(*http.Response) goahttp.Decoder +} + +// NewClient instantiates HTTP clients for all the web service servers. +func NewClient( + scheme string, + host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restoreBody bool, +) *Client { + return &Client{ + IndexDoer: doer, + AnotherDoer: doer, + RestoreResponseBody: restoreBody, + scheme: scheme, + host: host, + decoder: dec, + encoder: enc, + } +} + +// Index returns an endpoint that makes HTTP requests to the web service index +// server. +func (c *Client) Index() goa.Endpoint { + var ( + decodeResponse = DecodeIndexResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildIndexRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.IndexDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("web", "index", err) + } + return decodeResponse(resp) + } +} + +// Another returns an endpoint that makes HTTP requests to the web service +// another server. +func (c *Client) Another() goa.Endpoint { + var ( + decodeResponse = DecodeAnotherResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildAnotherRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.AnotherDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("web", "another", err) + } + return decodeResponse(resp) + } +} diff --git a/app/api/v1/gen/http/web/client/encode_decode.go b/app/api/v1/gen/http/web/client/encode_decode.go new file mode 100644 index 0000000..ebee749 --- /dev/null +++ b/app/api/v1/gen/http/web/client/encode_decode.go @@ -0,0 +1,118 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP client encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + + goahttp "goa.design/goa/v3/http" +) + +// BuildIndexRequest instantiates a HTTP request object with method and path +// set to call the "web" service "index" endpoint +func (c *Client) BuildIndexRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: IndexWebPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("web", "index", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeIndexResponse returns a decoder for responses returned by the web +// index endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeIndexResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body []byte + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("web", "index", err) + } + return body, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("web", "index", resp.StatusCode, string(body)) + } + } +} + +// BuildAnotherRequest instantiates a HTTP request object with method and path +// set to call the "web" service "another" endpoint +func (c *Client) BuildAnotherRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: AnotherWebPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("web", "another", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeAnotherResponse returns a decoder for responses returned by the web +// another endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeAnotherResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body []byte + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("web", "another", err) + } + return body, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("web", "another", resp.StatusCode, string(body)) + } + } +} diff --git a/app/api/v1/gen/http/web/client/paths.go b/app/api/v1/gen/http/web/client/paths.go new file mode 100644 index 0000000..6ea65ce --- /dev/null +++ b/app/api/v1/gen/http/web/client/paths.go @@ -0,0 +1,18 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// HTTP request path constructors for the web service. +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client + +// IndexWebPath returns the URL path to the web service index HTTP endpoint. +func IndexWebPath() string { + return "/" +} + +// AnotherWebPath returns the URL path to the web service another HTTP endpoint. +func AnotherWebPath() string { + return "/another" +} diff --git a/app/api/v1/gen/http/web/client/types.go b/app/api/v1/gen/http/web/client/types.go new file mode 100644 index 0000000..0f1ccd1 --- /dev/null +++ b/app/api/v1/gen/http/web/client/types.go @@ -0,0 +1,8 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP client types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package client diff --git a/app/api/v1/gen/http/web/server/encode_decode.go b/app/api/v1/gen/http/web/server/encode_decode.go new file mode 100644 index 0000000..57b11ff --- /dev/null +++ b/app/api/v1/gen/http/web/server/encode_decode.go @@ -0,0 +1,41 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP server encoders and decoders +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + "net/http" + + goahttp "goa.design/goa/v3/http" +) + +// EncodeIndexResponse returns an encoder for responses returned by the web +// index endpoint. +func EncodeIndexResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.([]byte) + ctx = context.WithValue(ctx, goahttp.ContentTypeKey, "text/html") + enc := encoder(ctx, w) + body := res + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// EncodeAnotherResponse returns an encoder for responses returned by the web +// another endpoint. +func EncodeAnotherResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.([]byte) + ctx = context.WithValue(ctx, goahttp.ContentTypeKey, "text/html") + enc := encoder(ctx, w) + body := res + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/app/api/v1/gen/http/web/server/paths.go b/app/api/v1/gen/http/web/server/paths.go new file mode 100644 index 0000000..a72edb5 --- /dev/null +++ b/app/api/v1/gen/http/web/server/paths.go @@ -0,0 +1,18 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// HTTP request path constructors for the web service. +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +// IndexWebPath returns the URL path to the web service index HTTP endpoint. +func IndexWebPath() string { + return "/" +} + +// AnotherWebPath returns the URL path to the web service another HTTP endpoint. +func AnotherWebPath() string { + return "/another" +} diff --git a/app/api/v1/gen/http/web/server/server.go b/app/api/v1/gen/http/web/server/server.go new file mode 100644 index 0000000..b1225c6 --- /dev/null +++ b/app/api/v1/gen/http/web/server/server.go @@ -0,0 +1,207 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP server +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server + +import ( + "context" + "net/http" + "path" + + web "github.com/jace-ys/countup/api/v1/gen/web" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// Server lists the web service endpoint HTTP handlers. +type Server struct { + Mounts []*MountPoint + Index http.Handler + Another http.Handler + Static http.Handler +} + +// MountPoint holds information about the mounted endpoints. +type MountPoint struct { + // Method is the name of the service method served by the mounted HTTP handler. + Method string + // Verb is the HTTP method used to match requests to the mounted handler. + Verb string + // Pattern is the HTTP request path pattern used to match requests to the + // mounted handler. + Pattern string +} + +// New instantiates HTTP handlers for all the web service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *web.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemStatic http.FileSystem, +) *Server { + if fileSystemStatic == nil { + fileSystemStatic = http.Dir(".") + } + fileSystemStatic = appendPrefix(fileSystemStatic, "/static/") + return &Server{ + Mounts: []*MountPoint{ + {"Index", "GET", "/"}, + {"Another", "GET", "/another"}, + {"Serve static/", "GET", "/static"}, + }, + Index: NewIndexHandler(e.Index, mux, decoder, encoder, errhandler, formatter), + Another: NewAnotherHandler(e.Another, mux, decoder, encoder, errhandler, formatter), + Static: http.FileServer(fileSystemStatic), + } +} + +// Service returns the name of the service served. +func (s *Server) Service() string { return "web" } + +// Use wraps the server handlers with the given middleware. +func (s *Server) Use(m func(http.Handler) http.Handler) { + s.Index = m(s.Index) + s.Another = m(s.Another) +} + +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return web.MethodNames[:] } + +// Mount configures the mux to serve the web endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountIndexHandler(mux, h.Index) + MountAnotherHandler(mux, h.Another) + MountStatic(mux, http.StripPrefix("/static", h.Static)) +} + +// Mount configures the mux to serve the web endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} + +// MountIndexHandler configures the mux to serve the "web" service "index" +// endpoint. +func MountIndexHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/", f) +} + +// NewIndexHandler creates a HTTP handler which loads the HTTP request and +// calls the "web" service "index" endpoint. +func NewIndexHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + encodeResponse = EncodeIndexResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "index") + ctx = context.WithValue(ctx, goa.ServiceKey, "web") + var err error + res, err := endpoint(ctx, nil) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + +// MountAnotherHandler configures the mux to serve the "web" service "another" +// endpoint. +func MountAnotherHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/another", f) +} + +// NewAnotherHandler creates a HTTP handler which loads the HTTP request and +// calls the "web" service "another" endpoint. +func NewAnotherHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + encodeResponse = EncodeAnotherResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "another") + ctx = context.WithValue(ctx, goa.ServiceKey, "web") + var err error + res, err := endpoint(ctx, nil) + if err != nil { + if err := encodeError(ctx, w, err); err != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + errhandler(ctx, w, err) + } + }) +} + +// appendFS is a custom implementation of fs.FS that appends a specified prefix +// to the file paths before delegating the Open call to the underlying fs.FS. +type appendFS struct { + prefix string + fs http.FileSystem +} + +// Open opens the named file, appending the prefix to the file path before +// passing it to the underlying fs.FS. +func (s appendFS) Open(name string) (http.File, error) { + switch name { + } + return s.fs.Open(path.Join(s.prefix, name)) +} + +// appendPrefix returns a new fs.FS that appends the specified prefix to file paths +// before delegating to the provided embed.FS. +func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { + return appendFS{prefix: prefix, fs: fsys} +} + +// MountStatic configures the mux to serve GET request made to "/static". +func MountStatic(mux goahttp.Muxer, h http.Handler) { + mux.Handle("GET", "/static/", h.ServeHTTP) + mux.Handle("GET", "/static/{*path}", h.ServeHTTP) +} diff --git a/app/api/v1/gen/http/web/server/types.go b/app/api/v1/gen/http/web/server/types.go new file mode 100644 index 0000000..573bc71 --- /dev/null +++ b/app/api/v1/gen/http/web/server/types.go @@ -0,0 +1,8 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web HTTP server types +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package server diff --git a/app/api/v1/gen/web/client.go b/app/api/v1/gen/web/client.go new file mode 100644 index 0000000..2a2f38a --- /dev/null +++ b/app/api/v1/gen/web/client.go @@ -0,0 +1,48 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web client +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package web + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Client is the "web" service client. +type Client struct { + IndexEndpoint goa.Endpoint + AnotherEndpoint goa.Endpoint +} + +// NewClient initializes a "web" service client given the endpoints. +func NewClient(index, another goa.Endpoint) *Client { + return &Client{ + IndexEndpoint: index, + AnotherEndpoint: another, + } +} + +// Index calls the "index" endpoint of the "web" service. +func (c *Client) Index(ctx context.Context) (res []byte, err error) { + var ires any + ires, err = c.IndexEndpoint(ctx, nil) + if err != nil { + return + } + return ires.([]byte), nil +} + +// Another calls the "another" endpoint of the "web" service. +func (c *Client) Another(ctx context.Context) (res []byte, err error) { + var ires any + ires, err = c.AnotherEndpoint(ctx, nil) + if err != nil { + return + } + return ires.([]byte), nil +} diff --git a/app/api/v1/gen/web/endpoints.go b/app/api/v1/gen/web/endpoints.go new file mode 100644 index 0000000..280b9d1 --- /dev/null +++ b/app/api/v1/gen/web/endpoints.go @@ -0,0 +1,50 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web endpoints +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package web + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Endpoints wraps the "web" service endpoints. +type Endpoints struct { + Index goa.Endpoint + Another goa.Endpoint +} + +// NewEndpoints wraps the methods of the "web" service with endpoints. +func NewEndpoints(s Service) *Endpoints { + return &Endpoints{ + Index: NewIndexEndpoint(s), + Another: NewAnotherEndpoint(s), + } +} + +// Use applies the given middleware to all the "web" service endpoints. +func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { + e.Index = m(e.Index) + e.Another = m(e.Another) +} + +// NewIndexEndpoint returns an endpoint function that calls the method "index" +// of service "web". +func NewIndexEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + return s.Index(ctx) + } +} + +// NewAnotherEndpoint returns an endpoint function that calls the method +// "another" of service "web". +func NewAnotherEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + return s.Another(ctx) + } +} diff --git a/app/api/v1/gen/web/service.go b/app/api/v1/gen/web/service.go new file mode 100644 index 0000000..6aaa0e1 --- /dev/null +++ b/app/api/v1/gen/web/service.go @@ -0,0 +1,36 @@ +// Code generated by goa v3.19.1, DO NOT EDIT. +// +// web service +// +// Command: +// $ goa gen github.com/jace-ys/countup/api/v1 -o api/v1 + +package web + +import ( + "context" +) + +// Service is the web service interface. +type Service interface { + // Index implements index. + Index(context.Context) (res []byte, err error) + // Another implements another. + Another(context.Context) (res []byte, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "countup" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "1.0.0" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "web" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [2]string{"index", "another"} diff --git a/app/atlas.hcl b/app/atlas.hcl new file mode 100644 index 0000000..75f4a1b --- /dev/null +++ b/app/atlas.hcl @@ -0,0 +1,9 @@ +env "dev" { + src = "file://schema/schema.sql" + dev = "docker://postgres/15/dev" + + migration { + dir = "file://schema/migrations" + format = "goose" + } +} \ No newline at end of file diff --git a/app/cmd/countup-cli/grpc.go b/app/cmd/countup-cli/grpc.go new file mode 100644 index 0000000..753d3c1 --- /dev/null +++ b/app/cmd/countup-cli/grpc.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" + + cli "github.com/jace-ys/countup/api/v1/gen/grpc/cli/countup" + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + } + return cli.ParseEndpoint(conn) +} diff --git a/app/cmd/countup-cli/http.go b/app/cmd/countup-cli/http.go new file mode 100644 index 0000000..dd87b0f --- /dev/null +++ b/app/cmd/countup-cli/http.go @@ -0,0 +1,39 @@ +package main + +import ( + "net/http" + "time" + + cli "github.com/jace-ys/countup/api/v1/gen/http/cli/countup" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + return cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + ) +} + +func httpUsageCommands() string { + return cli.UsageCommands() +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/app/cmd/countup-cli/main.go b/app/cmd/countup-cli/main.go new file mode 100644 index 0000000..c16bf71 --- /dev/null +++ b/app/cmd/countup-cli/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "dev-http", "Server host (valid values: dev-http, dev-grpc)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "dev-http": + addr = "http://localhost:8080" + case "dev-grpc": + addr = "grpc://localhost:8081" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: dev-http|dev-grpc)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + var ( + endpoint goa.Endpoint + payload any + err error + ) + { + switch scheme { + case "http", "https": + endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + case "grpc", "grpcs": + endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + + data, err := endpoint(context.Background(), payload) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + if data != nil { + m, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(m)) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s is a command line client for the countup API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (dev-http). valid values: dev-http, dev-grpc + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(httpUsageCommands()), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.Replace(s, "\n", "\n ", -1) +} diff --git a/app/cmd/countup/globals.go b/app/cmd/countup/globals.go new file mode 100644 index 0000000..8cb1178 --- /dev/null +++ b/app/cmd/countup/globals.go @@ -0,0 +1,10 @@ +package main + +import ( + "io" +) + +type Globals struct { + Debug bool `env:"DEBUG" help:"Enable debug logging."` + Writer io.Writer `kong:"-"` +} diff --git a/app/cmd/countup/main.go b/app/cmd/countup/main.go new file mode 100644 index 0000000..db56b13 --- /dev/null +++ b/app/cmd/countup/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/alecthomas/kong" + + "github.com/jace-ys/countup/api/v1/gen/api" + "github.com/jace-ys/countup/internal/slog" +) + +type RootCmd struct { + Globals + + Migrate MigrateCmd `cmd:"" help:"Run database migrations."` + Server ServerCmd `cmd:"" help:"Run the countup server."` + Version VersionCmd `cmd:"" help:"Show version information."` +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + go func() { + <-ctx.Done() + stop() + }() + + var root RootCmd + cli := kong.Parse(&root, + kong.Name(api.APIName), + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{ + Compact: true, + FlagsLast: true, + NoExpandSubcommands: true, + }), + ) + + root.Writer = os.Stdout + ctx = slog.NewContext(ctx, root.Writer, root.Debug) + + cli.BindTo(ctx, (*context.Context)(nil)) + cli.FatalIfErrorf(cli.Run(&root.Globals)) +} diff --git a/app/cmd/countup/migrate.go b/app/cmd/countup/migrate.go new file mode 100644 index 0000000..a66a46b --- /dev/null +++ b/app/cmd/countup/migrate.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/jace-ys/countup/internal/postgres" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/schema/migrations" +) + +type MigrateCmd struct { + Command string `arg:"" help:"The command to pass to goose migrate."` + Args []string `arg:"" optional:"" passthrough:"" help:"Additional args to pass to goose migrate."` + + Database struct { + ConnectionURI string `env:"CONNECTION_URI" required:"" help:"Connection URI for connecting to the database."` + } `embed:"" envprefix:"DATABASE_" prefix:"database."` + + Migrations struct { + Dir string `env:"DIR" default:"." help:"Path to the directory containing migration files."` + LocalFS bool `env:"LOCALFS" help:"Allows discovering of migration files from OS filesystem."` + } `embed:"" envprefix:"MIGRATIONS_" prefix:"migrations."` +} + +func (c *MigrateCmd) Run(ctx context.Context, g *Globals) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + db, err := postgres.NewPool(ctx, c.Database.ConnectionURI) + if err != nil { + return fmt.Errorf("init db pool: %w", err) + } + defer db.Close() + + if err := migrations.WithRiverMigrate(db); err != nil { + return fmt.Errorf("init river migrate: %w", err) + } + + if err := goose.SetDialect(string(goose.DialectPostgres)); err != nil { + return fmt.Errorf("set goose dailect: %w", err) + } + + conn := stdlib.OpenDBFromPool(db) + defer func() { + if err := conn.Close(); err != nil { + slog.Error(ctx, "error closing db connection", err) + } + }() + + if !c.Migrations.LocalFS { + goose.SetBaseFS(migrations.FSDir) + } + + if err := goose.RunContext(ctx, c.Command, conn, c.Migrations.Dir, c.Args...); err != nil { + return fmt.Errorf("run goose command: %w", err) + } + + return nil +} diff --git a/app/cmd/countup/server.go b/app/cmd/countup/server.go new file mode 100644 index 0000000..cb3a252 --- /dev/null +++ b/app/cmd/countup/server.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "fmt" + "time" + + apiv1 "github.com/jace-ys/countup/api/v1" + genapi "github.com/jace-ys/countup/api/v1/gen/api" + apipb "github.com/jace-ys/countup/api/v1/gen/grpc/api/pb" + grpcapi "github.com/jace-ys/countup/api/v1/gen/grpc/api/server" + httpapi "github.com/jace-ys/countup/api/v1/gen/http/api/server" + httpweb "github.com/jace-ys/countup/api/v1/gen/http/web/server" + genweb "github.com/jace-ys/countup/api/v1/gen/web" + "github.com/jace-ys/countup/internal/app" + "github.com/jace-ys/countup/internal/endpoints" + "github.com/jace-ys/countup/internal/handler/api" + "github.com/jace-ys/countup/internal/handler/web" + "github.com/jace-ys/countup/internal/instrument" + "github.com/jace-ys/countup/internal/postgres" + "github.com/jace-ys/countup/internal/service/counter" + counterstore "github.com/jace-ys/countup/internal/service/counter/store" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/transport" + "github.com/jace-ys/countup/internal/worker" +) + +type ServerCmd struct { + Port int `env:"PORT" default:"8080" help:"Port for application server to listen on."` + AdminPort int `env:"ADMIN_PORT" default:"9090" help:"Port for admin server to listen on."` + + OTLP struct { + MetricsEndpoint string `env:"METRICS_ENDPOINT" default:"127.0.0.1:4317" help:"OTLP gRPC endpoint to send OpenTelemetry metrics to."` + TracesEndpoint string `env:"TRACES_ENDPOINT" default:"127.0.0.1:4317" help:"OTLP gRPC endpoint to send OpenTelemetry traces to."` + } `embed:"" envprefix:"OTLP_" prefix:"otlp."` + + Database struct { + ConnectionURI string `env:"CONNECTION_URI" required:"" help:"Connection URI for connecting to the database."` + } `embed:"" envprefix:"DATABASE_" prefix:"database."` + + Worker struct { + Concurrency int `env:"CONCURRENCY" default:"50" help:"Number of workers to run in the worker pool."` + } `embed:"" envprefix:"WORKER_" prefix:"worker."` + + Counter struct { + FinalizeWindow time.Duration `env:"FINALIZE_WINDOW" default:"1m" help:"Time period to wait before finalizing counter increments."` + } `embed:"" envprefix:"COUNTER_" prefix:"counter."` +} + +func (c *ServerCmd) Run(ctx context.Context, g *Globals) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + instrument.MustInitOTelProvider(ctx, genapi.APIName, genapi.APIVersion, c.OTLP.MetricsEndpoint, c.OTLP.TracesEndpoint) + defer func() { + if err := instrument.OTel.Shutdown(context.Background()); err != nil { + slog.Error(ctx, "error shutting down otel provider", err) + } + }() + + db, err := postgres.NewPool(ctx, c.Database.ConnectionURI) + if err != nil { + return fmt.Errorf("init db pool: %w", err) + } + defer db.Close() + + worker, err := worker.NewPool(ctx, "app.worker", db, g.Writer, c.Worker.Concurrency) + if err != nil { + return fmt.Errorf("init worker pool: %w", err) + } + + httpsrv := app.NewHTTPServer(ctx, "app.http", c.Port) + grpcsrv := app.NewGRPCServer[apipb.APIServer](ctx, "app.grpc", c.Port+1) + + admin := app.NewAdminServer(ctx, c.AdminPort, g.Debug) + admin.Administer(httpsrv, grpcsrv, worker) + + { + countersvc := counter.New(db, worker, counterstore.New(), c.Counter.FinalizeWindow) + // usersvc := counter.New(db, counterstore.New()) + + handler, err := api.NewHandler(worker, countersvc) + if err != nil { + return fmt.Errorf("init handler: %w", err) + } + admin.Administer(handler) + + ep := endpoints.Goa(genapi.NewEndpoints).Adapt(handler) + + { + transport := transport.GoaHTTP(httpapi.New, httpapi.Mount) + httpsrv.RegisterHandler("/api/v1", transport.Adapt(ctx, ep, apiv1.OpenAPIFS)) + } + { + transport := transport.GoaGRPC(grpcapi.New) + grpcsrv.RegisterHandler(&apipb.API_ServiceDesc, transport.Adapt(ctx, ep)) + } + } + + { + handler, err := web.NewHandler() + if err != nil { + return fmt.Errorf("init web: %w", err) + } + admin.Administer(handler) + + ep := endpoints.Goa(genweb.NewEndpoints).Adapt(handler) + + transport := transport.GoaHTTP(httpweb.New, httpweb.Mount) + httpsrv.RegisterHandler("/", transport.Adapt(ctx, ep, web.StaticFS)) + } + + err = app.New(httpsrv, grpcsrv, admin, worker).Run(ctx) + if err != nil { + slog.Error(ctx, "encountered error while running app", err) + return fmt.Errorf("app run: %w", err) + } + + return nil +} diff --git a/app/cmd/countup/version.go b/app/cmd/countup/version.go new file mode 100644 index 0000000..b6c510f --- /dev/null +++ b/app/cmd/countup/version.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "runtime" + + "github.com/jace-ys/countup/internal/versioninfo" +) + +type VersionCmd struct { +} + +func (c *VersionCmd) Run(ctx context.Context, g *Globals) error { + fmt.Printf( + "Version: %v\nGit SHA: %v\nGo Version: %v\nGo OS/Arch: %v/%v\n", + versioninfo.Version, versioninfo.CommitSHA, runtime.Version(), runtime.GOOS, runtime.GOARCH, + ) + return nil +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..560f040 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,75 @@ +module github.com/jace-ys/countup + +go 1.23.2 + +require ( + github.com/alecthomas/kong v1.2.1 + github.com/alexliesenfeld/health v0.8.0 + github.com/exaring/otelpgx v0.6.2 + github.com/go-chi/chi/v5 v5.1.0 + github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 + github.com/jackc/pgx/v5 v5.7.1 + github.com/pressly/goose/v3 v3.22.1 + github.com/riverqueue/river v0.13.0 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.13.0 + github.com/riverqueue/river/rivertype v0.13.0 + github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 + go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 + go.opentelemetry.io/otel/metric v1.31.0 + go.opentelemetry.io/otel/sdk v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 + goa.design/clue v1.0.7 + goa.design/goa/v3 v3.19.1 + golang.org/x/sync v0.8.0 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.35.1 +) + +require ( + github.com/aws/smithy-go v1.22.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/riverqueue/river/riverdriver v0.13.0 // indirect + github.com/riverqueue/river/rivershared v0.13.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // 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 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/goleak v1.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/tools v0.26.0 // indirect + google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..694f93a --- /dev/null +++ b/app/go.sum @@ -0,0 +1,193 @@ +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.2.1 h1:E8jH4Tsgv6wCRX2nGrdPyHDUCSG83WH2qE4XLACD33Q= +github.com/alecthomas/kong v1.2.1/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk= +github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= +github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= +github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= +github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/exaring/otelpgx v0.6.2 h1:z1ayuDusPITNOhzvmx3nLpFax+tv7Hu7mdrjtgW3ZeA= +github.com/exaring/otelpgx v0.6.2/go.mod h1:DuRveXIeRNz6VJrMTj2uCBFqiocMx4msCN1mIMmbZUI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b/go.mod h1:Bj8LjjP0ReT1eKt5QlKjwgi5AFm5mI6O1A2G4ChI0Ag= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= +github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/riverqueue/river v0.13.0 h1:BvEJfXAnHJ7HwraoPZWiD271t2jDVvX1SPCtvLzojiA= +github.com/riverqueue/river v0.13.0/go.mod h1:SOG+j28RQpKDsTA8AlfxjFdYpoPm+MSOio+Ev4ljN2U= +github.com/riverqueue/river/riverdriver v0.13.0 h1:UVzMtNfp3R+Ehr/yaRqgF58YOFEWGVqIAamCeK7RMkA= +github.com/riverqueue/river/riverdriver v0.13.0/go.mod h1:pxmx6qmGl+dNCrfa+xuktg8zrrZO3AEqlUFlFWOy8U4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.13.0 h1:xiiwQVFUoPv/7PQIsEIerpw2ux1lZ14oZScgiB4JHdE= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.13.0/go.mod h1:f7TWWD965tE6v96qi1Y40IP2shsAai0qJBHbqT7yFLM= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.13.0 h1:wjLgea/eI5rIMh0+TCjS+/+dsULIst3Wu8bZQo2DHno= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.13.0/go.mod h1:Vzt3E33kNks2vN9lTgLJL8VFrbcAWDbwzyZLo02FlBk= +github.com/riverqueue/river/rivershared v0.13.0 h1:AqRP54GgtwoLIvV5eoZmOGOCZXL8Ce5Zm8s60R8NKOA= +github.com/riverqueue/river/rivershared v0.13.0/go.mod h1:vzvawQpDy2Z1U5chkvh1NykzWNkRhc9RLcURsJRhlbE= +github.com/riverqueue/river/rivertype v0.13.0 h1:PkT3h9tP0ZV3h0EGy2MiwEhgZqpRMN4fXfj27UKc9Q0= +github.com/riverqueue/river/rivertype v0.13.0/go.mod h1:wVOhGBeay6+JcIi0pTFlF4KtUgHYFkhMYv8dpxU46W0= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 h1:yMkBS9yViCc7U7yeLzJPM2XizlfdVvBRSmsQDWu6qc0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0/go.mod h1:n8MR6/liuGB5EmTETUBeU5ZgqMOlqKRxUaqPQBOANZ8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0 h1:s7wHG+t8bEoH7ibWk1nk682h7EoWLJ5/8j+TSO3bX/o= +go.opentelemetry.io/contrib/instrumentation/runtime v0.56.0/go.mod h1:Q8Hsv3d9DwryfIl+ebj4mY81IYVRSPy4QfxroVZwqLo= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 h1:ZsXq73BERAiNuuFXYqP4MR5hBrjXfMGSO+Cx7qoOZiM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0/go.mod h1:hg1zaDMpyZJuUzjFxFsRYBoccE86tM9Uf4IqNMUxvrY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.31.0 h1:HZgBIps9wH0RDrwjrmNa3DVbNRW60HEhdzqZFyAp3fI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.31.0/go.mod h1:RDRhvt6TDG0eIXmonAx5bd9IcwpqCkziwkOClzWKwAQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.31.0 h1:UGZ1QwZWY67Z6BmckTU+9Rxn04m2bD3gD6Mk0OIOCPk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.31.0/go.mod h1:fcwWuDuaObkkChiDlhEpSq9+X1C0omv+s5mBtToAQ64= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +goa.design/clue v1.0.7 h1:Z0qhUTvMMo2C7bxn9X7Wt4DXahGMdYuIg7pr3F+iLOs= +goa.design/clue v1.0.7/go.mod h1:z9vhVyNCV02Aggr20KilzR/QQigD/wuz+0uGvWr4MYk= +goa.design/goa/v3 v3.19.1 h1:jpV3LEy7YANzPMwm++Lu17RoThRJgXrPxdEM0A1nlOE= +goa.design/goa/v3 v3.19.1/go.mod h1:astNE9ube0YCxqq7DQkt1MtLxB/b3kRPEFkEZovcO2I= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53 h1:Df6WuGvthPzc+JiQ/G+m+sNX24kc0aTBqoDN/0yyykE= +google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53/go.mod h1:fheguH3Am2dGp1LfXkrvwqC/KlFq8F0nLq3LryOMrrE= +google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U= +google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk= +modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/app/internal/app/admin.go b/app/internal/app/admin.go new file mode 100644 index 0000000..85427df --- /dev/null +++ b/app/internal/app/admin.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/alexliesenfeld/health" + "github.com/go-chi/chi/v5" + "goa.design/clue/debug" + "goa.design/clue/log" + goahttp "goa.design/goa/v3/http" + + "github.com/jace-ys/countup/internal/healthz" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/transport/middleware/recovery" +) + +type AdminServer struct { + debug bool + srv *HTTPServer + mux *chi.Mux + checks []health.Check +} + +func NewAdminServer(ctx context.Context, port int, debug bool) *AdminServer { + return &AdminServer{ + debug: debug, + srv: NewHTTPServer(ctx, "admin", port), + mux: chi.NewRouter(), + } +} + +var _ Server = (*AdminServer)(nil) + +func (s *AdminServer) Name() string { + return s.srv.Name() +} + +func (s *AdminServer) Kind() string { + return s.srv.Kind() +} + +func (s *AdminServer) Addr() string { + return s.srv.Addr() +} + +func (s *AdminServer) Serve(ctx context.Context) error { + s.srv.srv.Handler = s.router(ctx) + if err := s.srv.srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serving admin server: %w", err) + } + return nil +} + +func (s *AdminServer) router(ctx context.Context) http.Handler { + s.mux.Get("/healthz", health.NewHandler(healthz.NewChecker(s.checks...))) + + goamux := goahttp.NewMuxer() + debug.MountPprofHandlers(debug.Adapt(goamux), debug.WithPrefix("/pprof")) + if s.debug { + debug.MountDebugLogEnabler(debug.Adapt(goamux), debug.WithPath("/settings")) + } + s.mux.Mount("/debug", goamux) + + logCtx := log.With(ctx, slog.KV("server", s.Name())) + return chainMiddleware(s.mux, + recovery.HTTP(logCtx), + slog.HTTP(logCtx), + debug.HTTP(), + ) +} + +func (s *AdminServer) Shutdown(ctx context.Context) error { + return s.srv.Shutdown(ctx) +} + +func (s *AdminServer) Administer(targets ...healthz.Target) { + for _, target := range targets { + s.checks = append(s.checks, target.HealthChecks()...) + } +} diff --git a/app/internal/app/application.go b/app/internal/app/application.go new file mode 100644 index 0000000..d175bf3 --- /dev/null +++ b/app/internal/app/application.go @@ -0,0 +1,69 @@ +package app + +import ( + "context" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/jace-ys/countup/internal/slog" +) + +type Application struct { + servers []Server +} + +func New(servers ...Server) *Application { + return &Application{ + servers: servers, + } +} + +type Server interface { + Name() string + Kind() string + Addr() string + Serve(ctx context.Context) error + Shutdown(ctx context.Context) error +} + +func (a *Application) Run(ctx context.Context) error { + g, ctx := errgroup.WithContext(ctx) + + for _, srv := range a.servers { + g.Go(func() error { + slog.Print(ctx, "server listening", + slog.KV("server", srv.Name()), + slog.KV("kind", srv.Kind()), + slog.KV("addr", srv.Addr()), + ) + return srv.Serve(ctx) + }) + } + + slog.Print(ctx, "application started") + <-ctx.Done() + slog.Print(ctx, "application shutting down gracefully") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for _, srv := range a.servers { + g.Go(func() error { + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error(ctx, "server shutdown error", err, + slog.KV("server", srv.Name()), + slog.KV("kind", srv.Kind()), + ) + } + slog.Print(ctx, "server shutdown complete", + slog.KV("server", srv.Name()), + slog.KV("kind", srv.Kind()), + ) + return nil + }) + } + + defer slog.Print(ctx, "application stopped") + return g.Wait() //nolint:wrapcheck +} diff --git a/app/internal/app/grpc.go b/app/internal/app/grpc.go new file mode 100644 index 0000000..b45b434 --- /dev/null +++ b/app/internal/app/grpc.go @@ -0,0 +1,131 @@ +package app + +import ( + "context" + "fmt" + "net" + + "github.com/alexliesenfeld/health" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "go.opentelemetry.io/otel/attribute" + "goa.design/clue/debug" + "goa.design/clue/log" + "google.golang.org/grpc" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/reflection/grpc_reflection_v1" + "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" + "google.golang.org/grpc/stats" + + "github.com/jace-ys/countup/internal/healthz" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/transport/middleware/idgen" + "github.com/jace-ys/countup/internal/transport/middleware/recovery" +) + +type GRPCServer[SS any] struct { + name string + addr string + srv *grpc.Server +} + +func NewGRPCServer[SS any](ctx context.Context, name string, port int) *GRPCServer[SS] { + addr := fmt.Sprintf(":%d", port) + + excludedMethods := map[string]bool{ + grpc_reflection_v1.ServerReflection_ServerReflectionInfo_FullMethodName: true, + grpc_reflection_v1alpha.ServerReflection_ServerReflectionInfo_FullMethodName: true, + healthpb.Health_Check_FullMethodName: true, + healthpb.Health_Watch_FullMethodName: true, + } + + logCtx := log.With(ctx, slog.KV("server", name)) + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor( + recovery.UnaryServerInterceptor(logCtx), + withMethodFilter(idgen.UnaryServerInterceptor(), excludedMethods), + withMethodFilter(slog.UnaryServerInterceptor(logCtx), excludedMethods), + withMethodFilter(debug.UnaryServerInterceptor(), excludedMethods), + ), + grpc.StatsHandler(otelgrpc.NewServerHandler( + otelgrpc.WithSpanAttributes(attribute.String("rpc.server.name", name)), + otelgrpc.WithFilter(func(info *stats.RPCTagInfo) bool { + return !excludedMethods[info.FullMethodName] + }), + )), + ) + + reflection.Register(srv) + healthpb.RegisterHealthServer(srv, healthz.NewGRPCHandler()) + + return &GRPCServer[SS]{ + name: name, + addr: addr, + srv: srv, + } +} + +func withMethodFilter(interceptor grpc.UnaryServerInterceptor, excluded map[string]bool) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + if excluded := excluded[info.FullMethod]; excluded { + return handler(ctx, req) + } + return interceptor(ctx, req, info, handler) + } +} + +func (s *GRPCServer[SS]) RegisterHandler(sd *grpc.ServiceDesc, ss SS) { + s.srv.RegisterService(sd, ss) +} + +var _ Server = (*GRPCServer[any])(nil) + +func (s *GRPCServer[SS]) Name() string { + return s.name +} + +func (s *GRPCServer[SS]) Kind() string { + return "grpc" +} + +func (s *GRPCServer[SS]) Addr() string { + return s.addr +} + +func (s *GRPCServer[SS]) Serve(ctx context.Context) error { + lis, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("tcp listener: %w", err) + } + + if err := s.srv.Serve(lis); err != nil { + return fmt.Errorf("serving grpc server: %w", err) + } + + return nil +} + +func (s *GRPCServer[SS]) Shutdown(ctx context.Context) error { + ok := make(chan struct{}) + + go func() { + s.srv.GracefulStop() + close(ok) + }() + + select { + case <-ok: + return nil + case <-ctx.Done(): + s.srv.Stop() + return ctx.Err() //nolint:wrapcheck + } +} + +var _ healthz.Target = (*GRPCServer[any])(nil) + +func (s *GRPCServer[SS]) HealthChecks() []health.Check { + return []health.Check{ + healthz.GRPCCheck(s.Name(), s.Addr()), + } +} diff --git a/app/internal/app/http.go b/app/internal/app/http.go new file mode 100644 index 0000000..bea276f --- /dev/null +++ b/app/internal/app/http.go @@ -0,0 +1,120 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/alexliesenfeld/health" + "github.com/go-chi/chi/v5" + "go.opentelemetry.io/otel/attribute" + "goa.design/clue/debug" + "goa.design/clue/log" + + "github.com/jace-ys/countup/internal/healthz" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/transport/middleware/idgen" + "github.com/jace-ys/countup/internal/transport/middleware/recovery" + "github.com/jace-ys/countup/internal/transport/middleware/telemetry" +) + +type HTTPServer struct { + name string + addr string + srv *http.Server + mux *chi.Mux +} + +func NewHTTPServer(ctx context.Context, name string, port int) *HTTPServer { + addr := fmt.Sprintf(":%d", port) + + return &HTTPServer{ + name: name, + addr: addr, + srv: &http.Server{ + Addr: addr, + ReadHeaderTimeout: time.Second, + }, + mux: chi.NewRouter(), + } +} + +func (s *HTTPServer) RegisterHandler(path string, h http.Handler) { + if path == "/" { + s.mux.Mount(path, h) + } else { + s.mux.Mount(path, http.StripPrefix(path, h)) + } +} + +var _ Server = (*HTTPServer)(nil) + +func (s *HTTPServer) Name() string { + return s.name +} + +func (s *HTTPServer) Kind() string { + return "http" +} + +func (s *HTTPServer) Addr() string { + return s.addr +} + +func (s *HTTPServer) Serve(ctx context.Context) error { + s.srv.Handler = s.router(ctx) + if err := s.srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serving http server: %w", err) + } + return nil +} + +func (s *HTTPServer) router(ctx context.Context) http.Handler { + s.mux.Get("/healthz", healthz.NewHTTPHandler()) + + excludedPaths := map[string]bool{ + "/healthz": true, + } + + logCtx := log.With(ctx, slog.KV("server", s.Name())) + return chainMiddleware(s.mux, + withPathFilter(telemetry.HTTP(attribute.String("http.server.name", s.Name())), excludedPaths), + recovery.HTTP(logCtx), + withPathFilter(idgen.HTTP(), excludedPaths), + withPathFilter(slog.HTTP(logCtx), excludedPaths), + withPathFilter(debug.HTTP(), excludedPaths), + ) +} + +func chainMiddleware(h http.Handler, m ...func(http.Handler) http.Handler) http.Handler { + for i := len(m) - 1; i >= 0; i-- { + h = m[i](h) + } + return h +} + +func withPathFilter(m func(http.Handler) http.Handler, excluded map[string]bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if excluded := excluded[r.URL.Path]; excluded { + next.ServeHTTP(w, r) + return + } + m(next).ServeHTTP(w, r) + }) + } +} + +func (s *HTTPServer) Shutdown(ctx context.Context) error { + return s.srv.Shutdown(ctx) //nolint:wrapcheck +} + +var _ healthz.Target = (*HTTPServer)(nil) + +func (s *HTTPServer) HealthChecks() []health.Check { + return []health.Check{ + healthz.HTTPCheck(s.Name(), fmt.Sprintf("http://%s/healthz", s.Addr())), + } +} diff --git a/app/internal/endpoints/goa.go b/app/internal/endpoints/goa.go new file mode 100644 index 0000000..4d3616d --- /dev/null +++ b/app/internal/endpoints/goa.go @@ -0,0 +1,45 @@ +package endpoints + +import ( + "goa.design/clue/debug" + "goa.design/clue/log" + goa "goa.design/goa/v3/pkg" + + "github.com/jace-ys/countup/internal/endpoints/middleware/goaerror" + "github.com/jace-ys/countup/internal/endpoints/middleware/tracer" +) + +type GoaAdapter[S any, E GoaEndpoints] struct { + newFunc GoaNewFunc[S, E] +} + +type GoaEndpoints interface { + Use(func(goa.Endpoint) goa.Endpoint) +} + +type GoaNewFunc[S any, E GoaEndpoints] func(svc S) E + +func Goa[S any, E GoaEndpoints](newFunc GoaNewFunc[S, E]) *GoaAdapter[S, E] { + return &GoaAdapter[S, E]{ + newFunc: newFunc, + } +} + +func (a *GoaAdapter[S, E]) Adapt(svc S) E { + ep := a.newFunc(svc) + + chainMiddleware(ep, + tracer.Endpoint, + log.Endpoint, + debug.LogPayloads(), + goaerror.Reporter, + ) + + return ep +} + +func chainMiddleware[E GoaEndpoints](ep E, m ...func(goa.Endpoint) goa.Endpoint) { + for i := len(m) - 1; i >= 0; i-- { + ep.Use(m[i]) + } +} diff --git a/app/internal/endpoints/middleware/goaerror/reporter.go b/app/internal/endpoints/middleware/goaerror/reporter.go new file mode 100644 index 0000000..393340b --- /dev/null +++ b/app/internal/endpoints/middleware/goaerror/reporter.go @@ -0,0 +1,36 @@ +package goaerror + +import ( + "context" + "errors" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + goa "goa.design/goa/v3/pkg" + + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/transport/middleware/idgen" +) + +func Reporter(e goa.Endpoint) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + res, err := e(ctx, req) + if err == nil { + return res, nil + } + + var gerr *goa.ServiceError + if !errors.As(err, &gerr) { + gerr = goa.Fault("an unexpected error occurred") + } + gerr.ID = idgen.RequestIDFromContext(ctx) + + span := trace.SpanFromContext(ctx) + span.SetStatus(codes.Error, gerr.Name) + span.SetAttributes(attribute.String("error", err.Error())) + slog.Error(ctx, "endpoint error", err, slog.KV("err.name", gerr.Name)) + + return res, gerr + } +} diff --git a/app/internal/endpoints/middleware/tracer/endpoint.go b/app/internal/endpoints/middleware/tracer/endpoint.go new file mode 100644 index 0000000..df5fa2b --- /dev/null +++ b/app/internal/endpoints/middleware/tracer/endpoint.go @@ -0,0 +1,31 @@ +package tracer + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + goa "goa.design/goa/v3/pkg" + + "github.com/jace-ys/countup/internal/instrument" +) + +func Endpoint(e goa.Endpoint) goa.Endpoint { + return func(ctx context.Context, req interface{}) (interface{}, error) { + service := "unknown" + if s, ok := ctx.Value(goa.ServiceKey).(string); ok { + service = s + } + + method := "unknown" + if m, ok := ctx.Value(goa.MethodKey).(string); ok { + method = m + } + + ctx, span := instrument.OTel.Tracer().Start(ctx, fmt.Sprintf("goa.endpoint/%s.%s", service, method)) + span.SetAttributes(attribute.String("endpoint.service", service), attribute.String("endpoint.method", method)) + defer span.End() + + return e(ctx, req) + } +} diff --git a/app/internal/handler/api/echo.go b/app/internal/handler/api/echo.go new file mode 100644 index 0000000..01b708a --- /dev/null +++ b/app/internal/handler/api/echo.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/riverqueue/river" + + "github.com/jace-ys/countup/api/v1/gen/api" +) + +func (h *Handler) Echo(ctx context.Context, req *api.EchoPayload) (*api.EchoResult, error) { + switch { + case strings.HasPrefix(req.Text, "error: "): + msg := strings.TrimPrefix(req.Text, "error: ") + h.workers.Enqueue(ctx, &EchoJobArgs{Error: msg}) + return nil, api.MakeUnauthorized(errors.New(msg)) + + case strings.HasPrefix(req.Text, "panic: "): + msg := strings.TrimPrefix(req.Text, "panic: ") + h.workers.Enqueue(ctx, &EchoJobArgs{Panic: msg}) + panic(msg) + + case strings.HasPrefix(req.Text, "cancel: "): + msg := strings.TrimPrefix(req.Text, "cancel: ") + h.workers.Enqueue(ctx, &EchoJobArgs{Cancel: msg}) + return nil, api.MakeUnauthorized(errors.New(msg)) + + case strings.HasPrefix(req.Text, "sleep: "): + msg := strings.TrimPrefix(req.Text, "sleep: ") + duration, err := time.ParseDuration(msg) + if err != nil { + duration = time.Second + } + time.Sleep(duration) + h.workers.Enqueue(ctx, &EchoJobArgs{Sleep: duration}) + } + + return &api.EchoResult{Text: req.Text}, nil +} + +type EchoJobArgs struct { + Error string + Panic string + Cancel string + Sleep time.Duration +} + +func (EchoJobArgs) Kind() string { + return "countup.Echo" +} + +type EchoWorker struct { + river.WorkerDefaults[EchoJobArgs] +} + +func (w *EchoWorker) Work(ctx context.Context, job *river.Job[EchoJobArgs]) error { + switch { + case job.Args.Error != "": + return errors.New(job.Args.Error) + case job.Args.Panic != "": + panic(job.Args.Panic) + case job.Args.Cancel != "": + return river.JobCancel(errors.New(job.Args.Cancel)) + case job.Args.Sleep != 0: + time.Sleep(job.Args.Sleep) + } + + return nil +} diff --git a/app/internal/handler/api/handler.go b/app/internal/handler/api/handler.go new file mode 100644 index 0000000..d7c9ac9 --- /dev/null +++ b/app/internal/handler/api/handler.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + + "github.com/alexliesenfeld/health" + + "github.com/jace-ys/countup/api/v1/gen/api" + "github.com/jace-ys/countup/internal/healthz" + "github.com/jace-ys/countup/internal/service/counter" + "github.com/jace-ys/countup/internal/worker" +) + +var _ api.Service = (*Handler)(nil) + +type Handler struct { + workers *worker.Pool + counter CounterService +} + +type CounterService interface { + GetInfo(ctx context.Context) (*counter.Info, error) + RequestIncrement(ctx context.Context, user string) error +} + +func NewHandler(workers *worker.Pool, counter CounterService) (*Handler, error) { + worker.Register(workers, &EchoWorker{}) + + return &Handler{ + workers: workers, + counter: counter, + }, nil +} + +var _ healthz.Target = (*Handler)(nil) + +func (h *Handler) HealthChecks() []health.Check { + return []health.Check{ + { + Name: "handler:countup", + Check: func(ctx context.Context) error { + return nil + }, + }, + } +} diff --git a/app/internal/handler/api/increment.go b/app/internal/handler/api/increment.go new file mode 100644 index 0000000..ce2bb12 --- /dev/null +++ b/app/internal/handler/api/increment.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "errors" + + goa "goa.design/goa/v3/pkg" + + "github.com/jace-ys/countup/api/v1/gen/api" + "github.com/jace-ys/countup/internal/service/counter" +) + +func (h *Handler) CounterGet(ctx context.Context) (*api.CounterInfo, error) { + info, err := h.counter.GetInfo(ctx) + if err != nil { + return nil, goa.Fault("get counter info: %s", err) + } + + return &api.CounterInfo{ + Count: info.Count, + LastIncrementBy: info.LastIncrementBy, + LastIncrementAt: info.LastIncrementAtTimestamp(), + NextFinalizeAt: info.NextFinalizeAtTimestamp(), + }, nil +} + +func (h *Handler) CounterIncrement(ctx context.Context, req *api.CounterIncrementPayload) (*api.CounterInfo, error) { + if err := h.counter.RequestIncrement(ctx, req.User); err != nil { + var multipleRequestErr *counter.MultipleIncrementRequestError + switch { + case errors.As(err, &multipleRequestErr): + return nil, api.MakeExistingIncrementRequest( + errors.New("user already made an increment request in the current finalize window, please try again after the next finalize time"), + ) + default: + return nil, goa.Fault("request increment: %s", err) + } + } + + info, err := h.counter.GetInfo(ctx) + if err != nil { + return nil, goa.Fault("get counter info: %s", err) + } + + return &api.CounterInfo{ + Count: info.Count, + LastIncrementBy: info.LastIncrementBy, + LastIncrementAt: info.LastIncrementAtTimestamp(), + NextFinalizeAt: info.NextFinalizeAtTimestamp(), + }, nil +} diff --git a/app/internal/handler/web/handler.go b/app/internal/handler/web/handler.go new file mode 100644 index 0000000..aa9f268 --- /dev/null +++ b/app/internal/handler/web/handler.go @@ -0,0 +1,69 @@ +package web + +import ( + "bytes" + "context" + "embed" + "fmt" + "html/template" + + "github.com/alexliesenfeld/health" + + "github.com/jace-ys/countup/api/v1/gen/web" + "github.com/jace-ys/countup/internal/healthz" +) + +var ( + //go:embed static/* + StaticFS embed.FS + + //go:embed templates/* + templateFS embed.FS +) + +var _ web.Service = (*Handler)(nil) + +type Handler struct { + tmpls *template.Template +} + +func NewHandler() (*Handler, error) { + tmpls, err := template.New("tmpls").ParseFS(templateFS, "**/*.html") + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + + return &Handler{ + tmpls: tmpls, + }, nil +} + +var _ healthz.Target = (*Handler)(nil) + +func (h *Handler) HealthChecks() []health.Check { + return []health.Check{ + { + Name: "handler:web", + Check: func(ctx context.Context) error { + return nil + }, + }, + } +} + +var commonVars = struct { + BaseAPIEndpoint string +}{ + BaseAPIEndpoint: "/api/v1", +} + +type renderData struct { + Vars any + Data any +} + +func (h *Handler) render(page string, data any) ([]byte, error) { + buf := &bytes.Buffer{} + err := h.tmpls.ExecuteTemplate(buf, page, renderData{commonVars, data}) + return buf.Bytes(), err +} diff --git a/app/internal/handler/web/pages.go b/app/internal/handler/web/pages.go new file mode 100644 index 0000000..24dcbf6 --- /dev/null +++ b/app/internal/handler/web/pages.go @@ -0,0 +1,25 @@ +package web + +import ( + "context" +) + +func (h *Handler) Index(ctx context.Context) ([]byte, error) { + data := struct { + Name string + }{ + Name: "Count Up!", + } + + return h.render("index.html", data) +} + +func (h *Handler) Another(ctx context.Context) ([]byte, error) { + data := struct { + Name string + }{ + Name: "Page", + } + + return h.render("another.html", data) +} diff --git a/app/internal/handler/web/static/assets/gopher.png b/app/internal/handler/web/static/assets/gopher.png new file mode 100644 index 0000000000000000000000000000000000000000..33e851451f48ca2ba92b37d04a0f4f8efd32f824 GIT binary patch literal 190838 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

wRg1Wc#=1e*29@dClW#VM+~&Ri~L zj2k^4qCAOzf6+8LAA}xn1K&V93-bDE*ubm+UQnR=VoP(qN0|rzyJuSqFB4`i&#vm+ zH#zm~rb*Z{U+!^E6#TL3*!pH)2vU@Ew7)$vW1)+0+x!Fp6chF%%7zWT0pN`QI;8xs zSn?v^I%15zKAdE9;n_qDnOxKmuill{*iO-H~O3JLu20X&K^ z8WCCYdPv|XX9Zd zAFxm=>A!CdGU$LMZ#|7xS%ysPQqJgLD|sl#OvFoIM`5in50-Q50*q2$fzHSDy zn5ou+{8fT|pmaY0VJ;Ml@(jBdCoU{=aY0%Wd(Px1UOsacSJZQWJH}CtF$CLesPkV9 z*|eU)35G$*()?P>9iP<5Tm1|$(1~K1JlR0Sm|o%*-ZDEMQ?na8T!&IF-G6WTMC5mo z42Bqx(ZN7(_4+AF1}RSs=+e-yF$+I^tS`sZ7WhHlz5K(XyS8J5Nnz!8az`Fq;}wXr+X>3__66^e%O^avN#68DHJ8qw*d`Q&G*{9vhRQeVGJ+G z7~n{4uE4QaGC`Ynw!tv3r1Fjv3{nI@%u2RTV-e#$Zc7OPofCucm&$xFri;~@4e#qf zXp0qmAHh+C#88gJLRH?u1~zT z66Oz1#85P-MD1 zdSkmEBsHWrah>qWmSwhVPCqvKGdlV+SnXtz&fz5SZDXJE>zg_#u^N9C6`s2UhmyH? zIsM2HxR>fZ_z+a$kWa|RNvgzY^Vdd4(5d(^JTk0%tvgbthAtnpff*j)o4YzY8*ld0 zu;bn|%Ny!INQ;|V9tZllek z=^`T|leOnH(GP*gqC+E`7aYFS5ZL(D)I;n9GA=->VAo`h-*^G zC@cOu6XJ6kraJh2iN`X5@Ew|@VPvjEC+r?bQXG%wOT8k=P(z8?co+FrFnVx+c$fS% zzUX>Siwj{nsS}A4p_E9al1x}EzV8xrAkDpSYQ!Kx|NFd{Po0>Y&mRbYqe6AsQKS#y z%wlZR9L;688Su~NH(S|zQ$g_m0C;Z(PbJ}!1XuO^99y+ybzYxP)hkUO zLI)StoGspC4T4e^5TK81^lBN$_WXsdm6Kv7PV>MA*Y!01N^rXAu|KvsMzb8wifUn! ze*t#R(Lv{;ouNraSW63+jaTw*>$Fiwr1Yw3<7C>G`v;lDD-}DUw z_D{8nW$jusKE(0M*4avJPZ++l7W#Fn!3+$~`M}D9vrGcqQkbw>l86Exb12aIi=anq zn~L^KH<2KPnY2!6W&n>;&0}SA*R${F+u4kfQO!l>pr#z)X2 zIjtptFUC|F;);k2oLiwWU$x&9ahFdugRyG&E3T=-VIDPIvAk!$);HXz$sSqI)vSU@ zBIx7xg66ajtmA4LD&=h*v!-qC;0%ZERDR0?dRifUf{w&jy#|;v3zC%aI$SZ}KYmRS z7+cyS(gYKFFNc!skzh|589K3HzcMt#PeI<8g1 zj0}5HB07++ZN?tMB-Nd0A>a`e2I7ErfCy0C>x@LO(CTcnXpGOfu%zn1GzwmU#;Q^G znreEt^y*-rPBdaX@xP`JGC}#=ggwpCrL=L*;iX@tOt&ga5lC9adCYyE%z4R#5D#u> zV$ES$kfpb640~J1z2cSkt;qI!(b(ey8tDnt3qVfH0cl7Q>lc&YEr^k%prHwsO=gk- zzD6!S*J<0?lCx|^x~6O$0i%2jF$y)5-mz_H`zG9 zz}P0+Uo4?-l&^^3P{tj6CRKILmon-ehnMMDZpTdPlOL~-eznfJwFfdRD zUPK1+e`!-FHa`9vV2I31ppCAy@pgC=@?1a72fhg%CuytsCxJfZ3=UbJLSQ!G0QF7v zpW7L{3!{wS{v2S_4NETP_Jr#0En*p$b0F><-2w-=Gf*CEs5&oGB z#NFnP&K%fmP3HPRZ}!Pb5)b)F15b&m%q2&q;j{P*)=Aa;N27@HacPYO$_?Bbue37@ zOIbzv5w1t@3XXtiul4E8#N_i}7#5EgdGiJWB0vggaHh8KK=J?NZ-&$V9M=Ny5SsEv zL<}xg5Qk2?iUPPQWvpf6AHSSORvx#om_#$ean!~I<0cZc z0VRgV88Ba%zy-`lT0_NYa?+ zT5ASkoGxtW}d90S^UK!9Y9#b7$8YW%#lATP*7g-b(8X1rD4k35>&#;Ly)*YaSNk}`%I1)W$})F zv=Jfx{qqLWt~1vjk{4F<>nBi26QR;pmuaGAMyg%?xky-8EMGp`!)*>Bpz|t>#jXt@jIY9ps0d{_^ zYp6 z;)oC4O!ZjtiRYIEXZM0jFY9ecYgOUPB+;qE2g+5U*7FMu&V9drNlB2hgLi?#$PFGO zJ!Hcr3rzc@aDl8k`zru(62;@`X8!d%E&9zFN>^$$*^cq)4-TW8;20|4A#aXOb8-je z9iJ-TjRNdoHJT#?&8oT7gl*;vLGT{0!&KPr+BJt_J(NlLN#o**)k`N&qki@-j^Hm} zxB>g;j@KF*H?zTduXF*=(>6p;{eEn);ljZK9(U6iKxA3cA=@R9n%o)0K(NAs-sUgE z#?Q;`(I7E#@&94bO`4G^V{s7LNL8)XQH+Q9qQoWGi5h{P$N&|nyGdw2O< ziOln&3rEbyfBpxz*@pD~+xj?CP^`w+IxL5?7P)D{RA7@1HQdRPtGj#AQ#3-N&xW#2 z?4C~%RYWMcsH#U1DUER9Kz3^VMQV0Ttyps7SId+A2*7ed{uH7eR+N02 z1DgPhQXnADr-ZvHT0oj9tSf=ePDq0doDKx#^D`YSMD}EohJT_c;Qq}z9iIIK(F(dz zt#nGbaY%+J-p9v3gJ>C#r`rG)e6IvX+s8W|p)ZOeYqUq#=3=`^)}$Dim^3m=z!CxO z=_bVrFiQbGM$1Mv-h*aY4&W5!P3##qiN8GE0|SwW=tOb62ojaB1AL4R$HT(|)(On3 zNvS<@+>Qt}I?^y)$dV2!!`CmpNZ(=PH$ER6QHvP@%^du}IJBOgG_aTFMgpImW>fj% z>S(FD?2BFUYfnIes-IO{F^|?Q);B!ttv3D zfq)%8qgl&EL`gv+o7g`*ADBLCH|aH;0b(`b`}glP0ub13ZXbg*x*sVH4GqaIO4}MH z{-%v%P9dLMxo%B+3-~Pg+ieo?LkuVmR$)^;h?-UJsbpep{sSj^^ZehUCT6~@d_0z?Ez8=aM5K>uiC#-RMfngGi72Jcj zzZl&f_|guS!@cjZg>+E@^?lw$cS>dZK2agbSRwsqXQ6=yfMEiV^aYf#d|%s{YN8pX z*<1MT^zQ{=EfUjwc;FO3bx)8qz=vbXh`?7Q^KhRzFb(^|k3R7FkAPKu*{?%|v!Kdg zCq};-dS5r-Q}+$A)KsnhYS|2wJdR$Tol%9ig#UY_$rQ>!N{aW0=!qcmaaVZk0}~cU zs)*P=24Az;SEtGr@2k+1N_J)#L^FfJz4gsgJv4~UMe}wm1r$IBB+J<;53o=)gqd^Ekj%Yy3}f}jtT!<%IGnBJgM`k%z`}1 zLBPMOpOVfoXz>CI_eweI{xA|U_KEhMLYzWE)EX2QnU_@<1cBKC6`vGzLI*u~(z%~j z5rbe|3q@V$a>_@hB)yXzBQJ-?tUm^vk`{;i=}E-C_fL80dsf)TGWE4+sk_FL!OLoi z9dCIWR;v+;0`teF+3m=gRvJ_8vNhnSq+7KpSIsby_uQ-a4$cz<_By9YtbmZC#_p}b z0yAHmb7VYIFq2Yy_uyw6EFznmn5pMe6(!h((I^L69$9}3fUcr4*m15`=F;pJ z9vUpz-R@BE4;lwV zcdx5O_h?KO`UjSSKXLGMX#n;8KFqi%JEZ_Qqx@!Dod%Wv=7BVn-u?tISuoannGB** z^CFw02f}mhs(L8o3t3paK#yv*=nn6JRgbhM-X0Si+z%@?1oV+&^1`MEGva>%OZD=7 zZXn(c@UjqNk|=<@km86Rf_QCz>5Ddbpol;=s%6lZbHzCx>>5s={9h@(J>SW%yZ6WP zzI%DDvS|Q6Dut>1dsMtXNQBAITpeck>@|ab)p8y`hOB6(rvO6YbbVrUrpOaM@1#TL z@Id`YJ&Q1aT~t6jQ2lJ4lUO+1^!L0W%`=Gqz7zgw3tKrW-E4B{D9*k0Ug<@5b_vc8 zZ|2a;#~W`O4QsNy{o3Kgwh_plCf%sMwh=4>qnK+3Cv`-!eB)9>b*Q{Gu z8$AO_j8X&)y&qovS2+COfh${6mW82B0xVLge?Z|KL8hkn2^kXPuJwIH%_0goNH zNf$hQB#$nQ&;LHYT{!*o8j8s3AiBkTBOthJz`T?kXhB+N^XrxHSkRZd{xfFPFUTzq zw|o7k|CwE#aX|E`CpIb5(eeQ?mntXI_K#Nlu-(EXXITNsgkxQ9+S_ z;^FPBmMJ&4cGeLT6>SKm3`C6a%9&5J6#3jwh+Io`9d0zi5V zW5=!A(f9QYlPc|rz*5&>OPjUEpMO>lGvq)Zy)cBN&hne;ghnBK7LZv1*h-0v^;j!~ zMIWZFAVQU&!d}5CsT^S1mtEFhtHOKc+jmQ|vOmvWXp#Xp$a=!AAHI?b{-I` z6raVSw6Sxi?*jS?=R6Th_+B~owb?&RkOu*KGm1f#m%Mx3?2+}=EJ>0iSR@?+bzJ&n zjy6{A-v}=wnoaYe4g55)Wd?gF%ZPazPjf>FfkeQV3%4lH!oZO-4VTZ- z3_`#%@L&0zczfPf9WA%~=|vvPY%(*fSZFa!zbN)dZeOK7qdU2*uw)#2J@&)Vv{YI2 zl=;6Nc!9k=J$&u{*z!Zy#pHs0?|I#e*mXc!xLnypjiLMc_()I6DVB)-M*|_^#ihi8 zL5OE=4_M7zF1l2}@$dD3k7sH%Nm(M?Bl`a&K=E71KRfq-p?WecL*LXy}eaX|Z+ ziIC_&klyqLNbi3v4i_%AyLV%g+;w$SR@jCX!d1A-oG_u^5(DdNxi!Ywl^Hu&2wdd>xB`FOA#o7 zi`&8DPJm9y?`*-*#J}O69Y)*a{CKtG>{n~c>%5g&YiZ6LkuWy*alzMWh|?BVyy~OE?j*Tq>RLv+rys*U-E~o1ZK)IIYcX=aP1uAD>I??xScN@-4mH5)b-rbQM zA~>!lA0!OoE(X8#RO;L3uE#5)IzJpJEA$%Tf$Sg!$VClvJsTT_aDTA3sy2F?6*K4Y> zi*`e z1Oyw@TYWrrM|fCRhRol8oWOw*!quj~=2S(a0B3r6xVTqyQH_nP{TWun-y#pKc2Gqo z@JlEZ@XH7a)Zayi=T!0!(@epr27b65w^USF$zX{T&G^(t@+O}n;g-q9i|P%%#x^9W zb!1Zgrug@I4NSBRMMtY)>?9V17zuG!-vp`UzmzR^vf7N>Z!Dgyl25f0B#311= z&V+LUH@hCfpk8d$w35e($?uzl+|br$RKl*FfAwS#U35f?BQX_h^lQ(7;D0|Mq{~e` zozD4s_PeK|7??THFh7-+3ArAL3Yg-|@F)pQYY)l1sd5KVEnciuwtttKE>daGB8}e~ z|86>g#p?gjbQNq-bzNJ=Q30hJ>244~O1itdJEc1ZkQNZ6OS+^xBqS8+knRRa>E_+@ zT;KNt&@(62UhA%9kS}h}|Lv><=_Ycecx)bMt9C(SUwfcM^2P)TVkEuoWRsY33gy$| z>D=ief91fbgD_arXX=Ma`ueCaw5p>>rL>p#!<3^|XOtTU+m*i|h&MJt7+=7h!B2#p z6*PZ}KQQiPCOS*>R|{u-VfBb<_1+#5@qWbWFJivIdqvuJ<)&yLZQ*OV3AfLakMOd# zVgUgH6DmlDjJ*7hHwDe3iV5DU@AcnfkBBJeiXmYp9!4*K;`-s* zaPwK$N2I>%^w+3a!7vFZA(#DN+U~~IITNwiAtgFChIIW4l z{vfAUhE7^;K486{y1%KgV!YPcSSltYnOU*^Cf75aa+}-#pRal&M=S^`i8E(=4h4U> zAY%kLgvdQo?fOqrEyakiHF1589tMY}bp~5W>pu*`iJ5$eFwjqiyEqg5c zO*imqV<(9M(fer9lg5T~PbtciD?0qE5muEY^Z)n$;xll|t{rL^DI$}@pKlXPXujb? z2}r@HVbqs@FN&AKr)o|sQWV`;!hEN13rwVMsOl*tW0bzo$XlIk{B~7kh_u;A`utf4 z)bD~Y67TQ|!w!2hO(QUfqq@_$lbi*Kg zpsP1BgRH44U-*r7PkafYDDz?LXrd-PaDd?G)EC8{6C#8?g@OQjl|&jd;ARY6jM#ia z@(N@yv0)&9@HAavX6j@yW7)bptftIN`32oXkj@>hyDIRQWX;vBD0naC_V-T?7%*(O zN>D9sz?f2O+#Tw=(uqK<^%P5}^9m|vunYne=y{zFd?^Ytf5JX$Z=ss$?=1A_tx~k3(w`FO%29lueM?pRo+79h@hw} zx+&|accQGUtc2`PdX5eH93U1D8i8_q`6=3Im0RMyGSwZA3r=Z8G|7_-2tPc#2ekj%-km3;O8iCdr#?4VSp@u<_ZMWc4IMa&#FtoMPICcGs`;b z2Krp3ZPoO26D_Fyx-0Alc>kZ%DvAAm$adU2Ji)PJpKzSdTv4(-Mnq=JNqNwg8gs__ ze4;_3*N=7sX8VXpY~{PkR3Du8|HU~hq633CO325LeRE%$9Jp4yN|`7$ws#NGwRy>H zmfQJt_w0>zbkdk-Zg2J~Tj#NhDp?_O{N86(o40N^CR$*MSI7q41QXiG{@n1m@Phe~<{;&9F7#ih#dBAEiVH2nc{j+m>0|jAQmGnEf06NUS>p1^4RLREug#Muwfw z{T;ZdLyqnO+vOw{bj1|5z^s4=uackSkwh)n0=RE+{z(idoxd$JQtuRFO!z5-6W$rF0r`3pN!n1 z&GBG@Z3z<0HKxC)FIGXD64x|z4stdATbkf=!9-0>tqk@^L=YS1+O!rWnCdO05fKr| zVW?C%DVI_5@}_~*70KMIOi!khW_V3(`U>-8#cI~tOe@YdXl{G+GV1sBW{m(ncOS}) zgt)je0EqQ0((+#FH#_$>PBvR62SXKCrGG?|N;=o!K`COV&AZ>&S} z5kXOdy<-Ii&DZ;r-=_Q61Z(`tM|izG;_X0Q_F4UDyN5x=QQIX+L{Ru!i^0)*o>tSh z4?(aj#6OjWhHrKl_xT$G&=PG%#S{y>arvOWcTe*d+fA(}2Y21Ey+|t-a=!Ua-h2;R z1)V!6g`qGLdb#CnvI*|JaLR{gWtmI4y&t7F=y7mGT|U8zU-|xH>tOJBe7Gmp?R%0Z zwqglXA`SDJy~7JOlci&(vHTYENX6ovnq=sFJxMJ3GOC4gVnQQto=`&|;BA1J}JvaG-bxuu$w3b`p`;AfaQ@OgZz)*^z+iDUzTMPydSY_q!ZQ{g2j=H=0FE9JCu z+*5r$&k0+sAMyovBi{9kv+f{74E2YjrRdMQLQ1l6zeIXfiLuJl7Z_*iAM@V|%hMlZ zr(N|dEeD8=YFciiZ^2K=B$$cV^njQ!o#g0qNwIiCsbWe|6#hb$Ru(mm!j>FA$Hsr! z1iSkXzOE@CEN-0#d#ME7_whlGHmIQ`zpvEdby`SsYQqx&um28Ui+~l7r|T$0H-m4| z86CaX706kgiJBi*Q}w5qX50jk+$4J}2MhATw1U$(taIlVsygm3UV{*>=*=)3@!W9h z4*z>Pa`GRMhuxOwjwwQRc4ccb8y{5@yw*c=Q6cx}$7syirBCb@a+fjACP#LIslkJ` zC(Fh^JFoM`pt3IdHW~DlBFteT?5ktqBc3XI;d^gQN-6MRzX-FN5)u&s{VJ+IG~W1qImKNL;!bEk{#}=*r~Kd zf@?!9-;If96!v4F7 z^ao9`0B-y{QurT;Omup>hBXP}IV~|ypYyb6i)n=JpK#6`2@2&E7cj684p~H7 z8~B~8v(^4A2~8$MFIEY`!~X8O(8fv2;W+87D)rTtqLH5JLQ^%TOkp&Y&k4WqnFFBXm4!2=C zDAEX$NJb(A@9c_G6fZ>X1Z3Yj_gudW^WyVkR4W$B$+;<& zuX0!x*!btRl5c5gIRU1AX}x?g(A<8eRRV+ANxaGweLW*>EWPw-r6@$5*Nv9+@kx?7 zs3B~^a6DKYGCTX-*U4gQeRa2h!T7tf_SLg6rA1@YPd4op`?%M>1>xiaG|Ku-dd<#E zHk1Eav1%tBHvZ2nN1w zZK$TTo^L2$W8uCqQ^1Y%Z3O6ZZUh$XII{%;Ic@sJpIsool4aJZ%YJJ)%;tve=A_~D+7^S$fc*af59!{iJB zKi*7UH*&mo$6RFJCZe^e?833O$8JHIs|bB;ajHILw4#MOWqX zi|Ash%~G{8iw_gIh=`0*^)CdibRP8%W>OdVW@IBN>?N#nXA}TPYz54@5^S1pUpE3x z6sW5-)vd@VVA^~G$yhAJLnC#xbAB1q;v4iexCRu57IN478#nKV>uopFSY;GxFwt6m z^_wc*>b6IBBtRI>bTtW{sDHVwZ-Iq79wRiyFiBUV@@efO;tc~u5S(Y|fwCw14)jZD z#%mRJEIl=q+p`7T@NX`*zPg9z@#?a40#|`7L&QNuVMFDra!@YoOnQ+ubN>wK0Ml^8 z!PR0zbVsl**UxTr^ihPsx%x!bwA>)xM=^cg;Spi&HgC6w9tDkuqkzXea2t8OaBSrs z7d@8806ky6%JdzI@B#^LNaj@8zYu>*hES5NJ8AESgCaSqiGcxycat=q3paE%T5`p# zktfcQZI?okHH+;C0}i<=b)eO~{bv0Y{^7V1^10ZZl1a*zhVR9-6% zfKqs%pCzkl$u+HFmK}Y-DidNcbtlS=0bf{@vd!#YangxqJ}>F(^v$%pMzrR*9q<}I zL0HF9`m|$_Z}7G~nb2#6{NCup#m)rbP~lhWzk*lj*HGx+Uu40$&i2dp4KDz-XllDi zj!h3TB6eVlVvn65!L<6{Olxw-mwDoC6x$%!5+aB(jw{Y*nMOZL%yV%tJh3(y<)WdH z;Nn>qyjppRd6Z){hoJ?)F@dl3-4UsBQe>bn-c0QK5@R6;dB`zVtCpwLUdK2~>q#^VYO4#DWUr*r=%#hwFJWHMoZxUfcT)NE2(#w5TTY{kW8qZNA zP>-A;k;2Qa)15B_NOp3i zIsKtDv8Q7|c(V(TZNkBi^v+mUAWUFqkYH(#{89_da!H)EmLh?E#j5)(;6@rXC<>4-Of%fTkW9}Pb0EoAvz6Rx%+AWH8O2Yw4b zADf&al)L1)3MST>Sq;kv={C=-51Og$<}*5$NKVt?wS5nKM}S?m1{r*hB%PF?A!vnm zb$1^LG+eGccfXkvHBDJ37tbwr4KDPWi^_J$(sBf(W+w#{8w=lDwlP|t$FGZm`%g1H zq-b&ZuJ1FClPwydus~qoU!qW*xXuF8FlLml;-X1I|IBOQ?y=V^<#^zf=Al5b`QH_rwHs(_WeMrL;P8U305e%Oz zkp*&%p>(-#jk}-|*U!R)glr)o<|HYT{T#x=`x_F^+icwkq`vfvyJ?r@6*9I~lN}&n z3De_qHqX~i01ner1}E>$UQZc4Lmx#81e;{cP4iz~5tNkl^U}G>XZuFEZ;%qYe~m`G zSqg)`06&RIY=!REbbj}p?i9~ys=R8uIiJn#pjvm6`nP5Dlb6Q62<)FEZ{D1pAWa3( zgI!LP{Ma+iq6*;U;YW~@84*KdgqLl%vC5xz3oAP4DVRhv z8GPrK-If~z5xW^Y{9D7qWMKUoNHxn`M9wNBh8Mi27YIHV4gt)(<6ZT6@!*RlSE)aEb;YK|a;blFcV{C9$ z&d>{bFJBe~K(=tX8XCih!0#J`=KfYB4i*1R})tR0fI|~PC!ty;|5L% z@#w^73)!BaIa!Fb27VoT{pHfA;8yjK_je7FLVw0q>U2}Nc-XC831!O^9bRHN)DfzF4WgiPsyt*!=%u} z7kXHhfr@(;bNlC{f;n2jMRwaMPtX9xehE*yq$#HG#cW{aU}`7)eC+r1w_n2L7{DOf?&u;1}?@k|PP zW)|#EO4s1JtRWyE;BeWJbKyfe66!>Ntgfy`=COZX`XNBMYeIa9yAky4<=od_zxi{s z&Gqy$;HQWe#!R)Tq(PX|D=3p}r>cy@FY7+qksLE3`9uZ&L&5={XA6nz{~5JYVqnFi z4a&d2!3X%D@9i=vhkL9_@6(?9on!TGC`+FSRAZt2A&As#SLm@#znN0qE3+hp_wZs6GQp!OrYA zx2m=brGO)E+gZS$hmhTkgI|m^V1L^&`(}yUIE) z%|O63JoBa5Q{9#sOpu&_eom^-2yG-R3W?1Db5eFI^11 zOP;v3sJcz%;Fi(#J1WZU!n9{rduv;8zP7L4Y?S= zsWsR-RKq2d#IPfhGMI)HqC!3Pm;ZKRl&3@0tRUq(wgR7q&vEd^kMH%rttC=?O{)1; zKs_66^!&GGUqtSnlMBuQz;KFCc(4OwK<6^_eg23@fsxr(^y^OZFj!i~m$eE^4>%Rl zWNTfgqUdKvqb7>Z{s5O!U^pclK(7S(JP)c!fLdpgs16%}LY+Y(fL+LQ_MZzVkT~ zY@vq0Uk$``fqL6+WJ19DNXTGUbxz@^n>7 z+^d6xJNZAWFMY^}5rMtVVl|gVT@iy6PTS%ru}Pj-y$)y z?_*|TDi3WUKHux1+1k6V!r_MA$WvM>Dy^{(SFa#mnCQK3JVOJ~c>UZL!g*P+qB@^e z=i3Uzn^MUz3@~3=P?0SLegC*Vy_y&R>I)kpc8N-KsQ>9}PMm zvfr$MbsLQP^c0ko@CysLT76+D7sYdceg#Zw^o)!H3aqHXuye$#+kx$dk)Mxvt*prQ zIpowo-SI|?*FjG}IP}q)h}`iX1?yr0rHkn+MEqzWZ{DLrjB5H9L%vW3kH0b5mDdW4 z@8P1&&z&a2O_bk~RV};;C6Q5rVTglMqykA2cXzbvSG2S5P4Q(ZD(s$z;8|Fc_md*% zdcWAhK74f(`3F@tsjA|Ltu0V4LP$jgvs^!lB1I-+)B!XE2)_qi^W$nRZJPYjH9z-d zmJokDNMWt2LCjI=9<`$?bueiJa++o zYKG)WF^5~m_!&jf(byzog4vFyG#N)Jx7*i$ikKO8s)1yj-OPeSGL^zKj#99lEHFX zAx6J_u3GKw#Mb}{<`_bQ(X@1sE>Tq$+?3uYKms0NL1)W7PTlV6VcCQQ=luwUUq>M@ z2CIVPJg_9>x)sUEN%OH1No$7}{^dGk`?h;cYkx|tqk&~tPKmuC1$D$2$;{HSHn$m2 z&3T|HL)j}@Lcx>X7Ds4M?x@U{FeRZT4UyUQx>kMJ?0ed^R%dDx)NX8StWYazy@tbB zC2JTF_x5IeZH?{?#!>39Q4~+Kq!0iU7nMIIaUq~7#=^hS^Z0hjrdo50$eYe7dy1yp zm0+|v6^4lHFMTdd$Yl|*eSjkGCTbewfF60p9BUV?I)0sTwhu*#o7a8t=&s-b1@<>4Fg>>Aj5>(x0 zKD9iI?GakT?kP^uBl8RyyYDdwb&w3Xm5AH1GcXQ6_Zh;*`D75q7TZ&V&1_cI$6Nxf z;`1L9uR}uru)Bn{1ML|$SYl=9e@$KQu$&d-4E@qmu?+CyiPI55MflcmxWusiR&%ar z6}7RN25~3;3D>VCFmSOPgehOs!m=G62uz-XQge#l#Oz0qIpAbD;vk65VCN#WX^5p& zgs$P@XJGn?Oqr9|(`KG-X$TwxBKp)un{$$pb^6u)Pi>5ZR-0 zp~Y*Wu6=v@jeSnee51p*M~QDVUZ6bVyVXnJDY63PNv*}zS{zDodEzp{F9{Y})D#3L z5p`lW@wL5}kn0obq=s^bp!nTAJ)ZwfZQ0EqMYgj?a;Sv^h}RZnYG=JiS%i=y#&FbRgPXZ?#_DTwQ%`wQmJ<&+7q+@C;ESkbE|r zieDQ9`+>NzaGsL9+sKY_HWU@b{j)9GXK#ub-ug9lh`*$e&*YuNJyU<#EFAp2&iC4R zoZhN{Za^68}QmY`7C4LR$w;hu3Q0zKQmXTQd3K^j}? z;!N95V?Zrq@9xt9&tsU`vTK@8O@ z4hetzrwMk9+hVNXj-f=-*)g->p5h7{MrArSfymNMisQ`umY>S@*MCi8UJ<@PBGPV7 zV6V_Ok7fa#m&`Z1tDkAu6u5!=c1z`Thb@~bY9|8zciaPb;Te)b*ayN-5x!w5^>amL zSKe!eORa8Tp&J<x+)J6!Fthi zl6$E;GBKB@2wTG(9-F89em5M=e82Ku4@Vv*y?5VFIkXEBCR?$AWL;(S<2iSbZJJ~s zqTT=z0f(Ugp|zux6&?TCsAyWM-gi$Y5O;6Xz7-W2*+1?1>N!F!x|EcZQ8Olyb><}# zF8Y^<eOZYLG3;Cjcoar|ZTpodQWVq)a4A=t*NkoKUfy2Hobb1w2J&hC zLx?#z(^?{4B@GF~TGy$5p}&VBOkgN^llJt=UaKCG>IfN-of~xcFP8K40F_XQRh~8! z5X@BNF8G4Solmd3u!xB>EC1oYf>@9{0!3gF3$cudH zB*!G48XE%Y!{wnkk-VVJhmelAvv1LyxxNGL*x2^v^Yq$AdQ>OJXU=0XG0ZLxcV54q znN{^s&DL!iEv)7Ts1>cXQrGotwu1HZNCrJj6#vR#x>7kE~Lm9D${Ld8-#6 z2<2{;A4=ycy2CaFLqXZrJ7W=aHVOp$Q7(?sCXB`4Avqvl0`WF*Y=j@8BHT!zS2^AG zNW&-eJ9A>3mwx7Tjif$Q|KG z_^wc=Csa&GrNYa50MGl&YugY3`$R8~C2er-J!8y3PLGWyIC_Cl9;po(*%<6>+@*tA!q(6NQhY4UpERX={k;YBlr0ofy$r-Ok`F6VB zEqo3g1{sOJk&3jQ_TnXuqoh~Wq4R{nl4DtIO+23Yefjc5`$Bj_55kQ$ycsncElkG9 zC_ANecw4ehMRWKnCfv+xd$=nOgoT*TTDdC2)YL|_OPCncOE=I--Y7u*;j=|JDyhI= zmlA*)lTay4F@;8_Q?^kwfaJI-6jnETdEt7f^3AecVis9&tI0)MAFsoc`lD0pM65hGL&XwqkXc1*j?vf&lNbp~X|%S^i_Wbk#M2ijRT z9$_E$p?tlIt)SIk>CYJm?sTG;m$;csc<+5EsMaE^Dne3v+uU@6=n)2MMc!*-EdTgz zBzWAL;&KhXO%_Pe&VX!61*pQL7ea}!utY!1)#U*7|LhHy!;&0e0#wr!+&az-O1)n_ zhrwnM=?*+EQF>xPNE3-cONQR%qJYzzfy^O-X8XlmMFU$9HoWbo^~E&C->?eeA^*e_ za{?S93 ztpLc!{R^j_O=y#_VfDl`F@hGWMGjALu*+BwFb$lY46sfBbh;RvpON2y_%<29Wgq6R z8GqVLX4D63G2XJFAvOiUR7R6;{r@8Co;QUSvmdRKW?Rhx6|#2g0yeF)(#ofomZ)iK zb;dwK;Yhd+=FgG_K03N^6H`<5*zPCjY?KIxA(cS|GU;P}R|&x|_vH;2xyUdun!t}? zipC?V3dI-6%=vk?n$DY}n`EDz4j72_YboFaonLTJiSIY}i)TAd1PC1~Mi)9IB_(#{ z8X}fUL8Xb&!)cPx4}!P*Z@pJT@YISvpz{TE-27!6XRxy0JY6X}jq(C*yQ%x9PvphasL>3x)GlNkmB(DfT2&E*b^*y;y^Id zeCgXAuuh!!pmq>6V9_4-9pzT&Ja-+YSp-e{hB z{}{^iJW^lFipdT0tCST*pnH;sA25Y2v!^>4HM+TU{AmbwSxaXR$v&N zULc8k*A?e74N@qFKK%GdW~2t@2vLkp2K-6!nIS4zHY*mk8T@XO9b9D6Dfg{?mTGe} zkU3?GrVOEZ5%@HKrSG5I1Gx%c->H+igOC-z503}b?pm4V_3xX%hOkF} z2EMomK1#;ks_*kP<}YQpnUnzllgU|czXgw8rEe>oB;>Q5VyYJ{1~CF^6-1SNU;qAo z1C*^2@II9(>I2^B`}bNp;h$u|iMyV~rv)~{t~>|C`>bk#`RpWhl7dfGMW*Gz;!2!< z()&8!H;cpVz3jy{$xBq?wczhE6v?v51XF0;Nt;@5m~GnhUx5wmMTDF-R-wEgZUe;V zC01VN=H{Mok^~EPzJew|%mNvf6o)#yF-R#Ge}3*e`1Ysa!~9m#T8_s)ko(g({R&A< zm+CzRYpkrcOaLy*Q{&SFmcgXW!Xkt^ccAYamIj@3q!Mj_X7?ve_MNr0-6Q;lgRVt> zr8Hq{)#)&QSM!VYzm14}BN`8bw|`N~^}5^{3!|<*MyiZ=gz&Z4GCqV9UmX0^08(L1 z*4>M95P6i>i~`1-q3I030FM0EALU`8Bd`}_vdr`cZh>>f@a1MtOaZJ;LtOZ~;)g4ScJzR%=oWwFvi;AeySb*PVJVPs-X ziVn8E&4wL;S2_msN}8}wLBPlR)?)4$iI4GP-a7*Ch3ElkSMPb6Bs2h9DqCR(Niu1% zV0wA`U~%^l_4l#qT z$agz7Ey_ybMBi37uXNH(zS+3@dD5_UE^zI|)BUyBq6ykV6S&I$YxKdM1|$a3Gi!G( zKXJ751>2{Jf!^LDYmF6AD-GtV$?##W=T;s^ow($r?AWAwn3aq%L;ecryjB<>;}*;F zXtGiI>nu&XQD{3mbBTJ*o33G+EQ#)-mbG^k3)OtGFXC*GpyFaRxN>rGX1jIRz@?}C z_XUvYqQHX%WIr}E16;6JIZpz0L>KfvVZThs5K{tSCZn2j)%e)PZA2R}8S}+|>$SQ; zmH!1kP%4&$h|bE{d4Mijf$TBV)@Cq2dEYr*M7v&=y|rIx^2u69LexryNyRx|19TgT z@T`lj%(D&%rPi^)>2tM>P68Ksg>_ofAK!jVE5K_zhU)XUi436+ST&arzc0vE?QO_p z>h6__2^GUNPW9OuBuG-Qjq8)PP1Gn)J9VxDX_t%SpMVSQ81Vy;O+_(!kHTy;>5~R8&>2I#G2@Hih*cbVjxZ z7n)5%PHyOPo60|rt3V>#`L;LlbRG1Jt_ni=Uij3rDLwV4Kqh+jEB_bW?G*DN z`ovyLnZXYh*RVgRH-0>hr%YepdyO+I0QcQA^InUoa`m?_|bx{qfYA^$ee*EeQrFFc!a?&R4k|BE* z$&{^^Ip#*V!5e=$k;u4cs4BdF2KqDWrRQnK#Zo2Y_HQ3Y0v<%cnx}loGh`(rBeFym z{WR~(LwQ5LbDYH{CrZOC!GAqi*hEAbl{{?t&_OXFJ;!Fp)u3G2rFMU{I)+O8!ZkDW zoh;A~w+M=}!KvgYP+YK)bx_T8JIk=6C|bpd>s#aC83%83T(VE8Z&bs^y2ByzjD$Zw zHr)@_=T2bfc5*8r4vshoi*vbdIn+{GVBag-+F1a-qs#w2o)uuvt!W6wUGIqPS1^kk zj(79IJMB?eE-PbdREcY+9S8mMUyav;b)Epbk$jd?{Pa1_<%BHLvJ_nlaQ}B^Y`Uwj%cZ(Gzc!I=Oz5XG`YgosRr#CmMq9-b-yX) z+r-#pR0bmUUg0&=FY@VXe&VVb6BvO_SCh4tijtuwax4(mjyraqw~5LXn}F+lC2aIN zo|=hIcFJLJT-Xd1%!z61@4H@tHtQ0rFTsEG{L7$HeXsrX-k06Zc)~|fi%AkWnc07H^!P~oQ!5N! zY3MmQ)u&cP3I_Tw@=gsP6qODDRRxNa?OE_a9-K8@9H%?9!&kOc=>IYMHd&#cF7XN9 zn7wiZ6>%dL)|bw#QtGHv@_b5~vwPLp9VMvULa_T74cyj5DCPsw-N_Mog8Ktg)(Mr< z>0HY=fn_DN`<3%O%W@5I#GR_u4UG~A8cPa%fkpyKI&S@k`6*1n2Jxj)z~xv)k2J`R z#p(CJHAdPbEk1HY_x`a4`CTCqGn*lNS^d|oW0oNtSR6{IFf^=Xd zi1FOk7IaAk$a$+F!&pYVX3k>8S(ekeDr)b_{*VY$;?i+Nb)^a~UtM`NYB3Hj0@0&N# zfAd*SZ}0CGs*{P_5?Z*aDK61O>*@ATIyg9WsmA$y&nGoE4h|0X3*v1C}cs1xAoOj2m(**ol zEUqjDQ-rrizH0I>gP>Ej*dOpID-iUJ11&hpSsr6@?wC>TNe*L4o7X@)|8u-qM6F1H z*c$`|>QyJuPoX0?J2grh8D-Po1!9q9QwL6d(*%`K6T9f__$-$_+RHSsu0)MIM7H;?=JN-Pp`LVZuh_U23}V)YkhK{?2b2ALETK=e(msG7zn}# zjyQlrs*vh){Ku14CRkj zG}1^Mlc<~@AC3*b|3L+Mnf?BSP{)N0iO4l{APv$oIGn8w1K5bw{!I07iiU?vWDp5l zbquI~KD&8PJf!oj&>K-pKbZdfM6S3Itr~^mjfIIWzM!J2YR<3f0v-azOZUiJu^jOz zdT$oz_(!l2IFeTrspobb*#Bg~GJEIA_)tB`eDgFmVfE%$qBlr+!krk9wcA;sfaoXG ziSg2e+Omhgf&;_d84UOUtT0l7Y!{Fc$yo(FUGapET+i~he z>B-1oz&TL<-}_I1WVg%`FrnyZhJnna)%U5Q^(Sq~6N77w18rMBFjO&ReiH-h70dB^ti4hkOgPniQUDK)!^u{)uaRX+f}x1k}Sf}Rlu z+k(c(1{%hJti`$1%lm&X?{8+LWIEDv-HYwdE!{s9z1C!@-CutQhc`R2h}h93!#a{s zzupQ)k9wf@ZLzgZu4H7G;<6fr2>=J}#T@Vk)Tm2&a9o}MsXX2L_Ze*@U=Fr`D}-|2 za1QRb%lfWAe+!b-rGSSI{OVOd?AvS86j|ArBby{Edx`TaJvUxhpY=L4S#8Mzzvmh#dX(x#~dfx!%3Y zsFBxzJxbRfH@-i6^DTW84BYB?TUs%MT2$^rkASgN1BmrgAA||v&Sq$ZL7NO%t7^)u zQvHDz07$18poRC{#wGy*=AGnDd-~&f3HGCS9jwX>Ug2fbJ$cFLG9^AWLvL%#?Kt%T zt#^sm<3RxR)7A+;6BUv_HUWs2{ad~LVj#S)nV<5Q?#fO1BZ|f^1~wC^0VvpJq3I1s zzv{WrBEs_WJk>Q*+&{r_$pilzdO=aq7rIcoX|YaBpq781f7g^a=VmTX5!8P8eWE8T z4n$BDftjk)pwT=AaO0nQnD;bV*lbh3yp-gmCPQv2C@7#Gp)ZguSqQv*D1J|XEkOxr z$gyGkeZZWs@doNYLjBc?yqmoE6a53$$mfoKKkhWBAuWU--2LEgN%IUf=T+gWV%t^N zz#|d?C`r{m*j4)!%>8k6sFd#QI@J-dU%$W5N<)`=S;YQv>jq!q0AC#(U@8m876=x~ zf{HP`R#qD`I6Xe2u-75-aap-qhBDOS)w?97OC7uWpn|VBEmfuI-kF;ltn4aA&uVr z)vV9i-g@4wm(%&jVBJpnJ4BZGx`HgZFlGis>~Gp2tpsejfnR!Y#=};+1Fx;O?C@<5 zmg48W?r;FFA9DqQrnWDaPNjh1&?b($=ZJ{`2a_4BKedI?p%zZlKssEu2FZa%v@LB1 zyA_`7x%MavOuiZHf;7(%-eSyj1aus9@&77f$n*i)fs_PLQfc_B9bbZydI=SaS*D3k z3?r^S>-ova;p51~+vZnG*}eE~tMBh-S$*E#AKJ%Jwj^ANdki`Z-B@!qJw2j`_ce!h zaB$S1&AWL5I#H%yru7D1)2;6UDao>*1p>@?ERCW1q@LcYi+>D zx0f1X2u3~$GDi{QyLXAN7PrEyOYlY}6akKTPCAmmU&bU^=(vj?H?_K!<2jYKsoP+> zOEyj$h*|%-3Plw;yo$Cz%yDZlwHP%E&EF%m{dAZQi~_#f^uTSmi4^YJ4p6w{O(lwk z3UJMn7~#82`aNHQfU|nz2i$I%Xl8%lFj<<&j82G!wXVUJ(RKy$C9J{G)u4AeZ1D0l z7)F4YUsyN}vTe00)a>Ttd?Ct|A*JI>XYu-p|oJNf%+Kx?`yBm`qAcZ6U1%9y_8r7++FNnHKyAY-tW4Gkx!=)BVXC0)2j7uuhwJTv`3kX z{^2lc)Gc=L+s#yEgvtM#85xOP3V8Ix3^|8jB(V4LgkV*Wvw^LaE#Bu0w$*Uf#uYsk z&J1kvHy*=NB)H*cuLD|%)vvIaw2Yg$UxrS}}|1 z!qxH-OGR4ad8XK(BgB1CuSm3~V}GAxEua0}wNc3^2XU$n?&Fj#dJ)%m${5K7%!ayX zTij;Bm6fc3;Z6jAogC=UJgiV(K@b?RwAy`nK!!kUNVddLV3{jd*>5s-CU8Wd`2013 zXXPLvEhPDRDyCp;Lq|LORcsk@sltZ#i<7VGw9@3N=R%}%%fo$W#K9)1xegnEzD zM7P%SpGm`1YS+$=mF~)Ps%^@LL)t9QAixY*ylY%RL)=L31Y2R?;BC!mg8d)82GYzJ zjN`M5!oU1pjU23cO`m}!bz?@v9v=scu(^ntRjn!oGWJ!S|EPgOL&oRwumAu8abm}K zz~>?a9%fk3#P|EV5*y8R1G2R%M3h+YzPwjZ0yiE8V?<&CSipJyGY`HNtEr(^I+qXW z96nMWn8HRLji$DmHFAh7dLJXzD*DGo@*N;b7Zs9#7m96R0q8jxk)I4KTD zNJ$p6Zyks$9r_RoI@)Q_7%Gj~aBfoV47@<7?v~$>+u~6EIBr!A(2F|D5fWXSOfdKD z&E5Tse*K?m_u=kZ4WMcI^T_LPb9#?BRso;X;SA|S0d2$x0SEeuA7&61965!-z9A8T ztBx*sZ+XqYoauUoe$Qrs&g`{I-{QZU`^~SaoHlZZc)2RH-aVCuc~)>-3rl1}15!Z9 zVO-AY|0+_Z$J=^s3cl9kZ!eMloH95=;kys}0JxWY@q-R-L&8{zw>2khBvWK%1xBV5 zVWB_I(XC0i2B7Pxg!7Q@@s|#kM)J#n{8;){OIY)Bu-!gP35`IiQ&u7nk$RgqNbDvX}<&)qbWh z_v%DA;J&D%*W_r7SNUmEs1qNmqM~B=gN4U7u3su9=AB>lQ!nAzvolwA75n7{*Sq_B z>%o*a4Za;~!vt}s)B1QDV#Xt2_3B*(Gw`HIV z@Wy3&84h6KPU+An@H;|j$WHG3|0%YFHQjkSpZ;{yIX!&M@hw1-w@$A=he18SO?T$40G=wHzZ_EJf0TBdwjVtR=)>SllW6200$F@FlviSZ!)5HpN`Dv zh=_=winNP---3XEpx`EpeL}=(19Z)G;h311QX53&Dg}|M(>)t-{j^DPWgS8Irh6ti zr8aMPnJ{SmZ8I!A;RB>E&`RHWLTzap_D!LWLF})ypGaD7;TM5cd_;@!m#AqS|~u@ZE0!~MoR<>a~g-jt+6VfuI$=m2iMOER{&3n-5XV$kse zwR}iuD1%Kj3woeY+{>$t-(MQ^e*ht2;Wy37cqun0ph430Ms&(itP211iAtHN!gY5X zo|J>>;hPO=7@%aOmMPp1U39iJmvR|0y&dyMwLXFphs!hOhy{*BS7=wk{MFJI_ea$6 z{#xX6KBc^|Sf$m-xy5)msn6`~L^044*TGU0L@irehfWYC)So)hK)OH_)q9X*()gKV z9zH5G{9I0YVdfRi# zw&40f8ZA&(#h~bi%4lQb#yAi20`K<|#TyGpt{cBKZr;X#SX|Zde<-$Ery%1; zq%&E(-;?6sGmcz6@uby`hnsKvhul+C3YZaUXn?EBC*d!v_sIo*`#Qj>Kz%dMTDt1C zDA_EBiL{IQIqU;rl}zLZe70wVLO>v;jn#2JrRxgpX`r11Z8Ey!yFjpHD+1>j$DITJ z4GDswReOeQl-vP%u`Jc0dr-4~w;@%9#q%@$Z%bYh(^HCg?*NZvi_UCrg-iqfL}n=rRk!pV{3*+mV8A zBFt=lc7x?1e?w{j1ow4F@&XB_NRy#vAx&7zI*}EoI^LCYgk+5R@X7rk>?q6UgQQ%t7#G-Oqsg>xZvaHjD8h!rNf1mzl%3-0KoH6}|HQvBlpCaRSp!_-TpuO5 z68(wZSupf%ak#>;S!u^+F|tlI2uQc@-;_911>7#LUccYH`p!@DoZc_UiHNbu#y$QJ zSU3mL*cF3`XclNQbi(I#t&H8E-gq|a>2JWZkv$qTFwg z`_WVs4}5Wgn+{+pQQIie^QZe}GJf;2uX_XSt4~fo3?u8@AIe`<$-U4y`AP8JY=UQM z6o;V}@ByU75;k7=wfb3Kli|5%Z?v(^-o7ofH#Je%kccWUYXP;=6&UmXwIN{Kxi@!z zcivh9cL7d6DKc29{=WQR@SH1OF*VxxSzeJ$d&Jr^ST(9pPrs(>yM_Pj1+aS!T0ltQ zGGod-4(a_k5Dwp5e9KQWCirLEM4M?r9Z3R8y5p>mCjSeK<~eVq#d8NOUwp|Ridx&qnmJsX^k2-|F~ z%_WR5N}(iZpB@P0bZO+p+_ryXYiy>(vV5a@bRkcHjI=h8ZdnBifQy=1mc|=xte2^B z@SKSPUQbqQyz+O~zZlZr{_ml)p*LOcHNL+%FdB~q0BOPVcP7ZzVnOoKAZLxFzUBi1 z=nr7$xRZ>q*5Ou!;i2sF6N>}7%?+BX?Y@M~-6s3=Dd(j5pH3o_*|_qulZTmOzhu80I0{M`UB6Sa{?zTyo<*ItzfAjkm^Xs-J* z5V+hlju3vwf|5T*9h+XA@^~PaWr%sNE~DYExK0qXd;b7gn%Ib~3c%q|BA3I&`^R~U zE&y?7&0oBk`Xr$vI(HdY>r6jiA()%<J@^~~>LQzNqVM+5`7|cK3=q5`(7z-)}qKTS?0Z>wY1I~60 zGncvmioM!v+_(8g0XU{&Ir1>KJ)=rzbWbn@Naxk^7kOcKTUx!5WQds zj)Gw+ADGmWRZ=qFrUB9Af!__T+fpzCBR6*(x8$Z&->VzodBqQagx(=hpvQ&`^L=~H zuUk&qtC~)fV*Q(U9-hBuPs_(dJ+I8G$#rS#XCP*I5yV`(FP7y%mEV^plzf|;@FKm4 z8;gu90|=ErgNj}Pr0g6pYNeU%Fz^AeAY8zGc)CPCs@Di4dd5)~92ziA)s3dt=n@9u za8p0x{<`M*!i*}&-ZzB9tL>VZKm00xf-RChpX z^9S!AZ9MIGiQLx2PZ{QuCxmMWs;`71uW{2FqkMs9RiWW8>(XW^u$|5|yHg7?rrbfY zlQ~uHil7>BsnnRK*$LGIQai4v2z2q!S1muQLT4l+1YVXy$v~8m#CXeAX!TaOb2;87 zBO|ECv;X6w6s-f$CtMw7(y+z=azqyF7vX#p`CP8owHY`9>69BIgW@*4#%@*?91v5^ zD1uAb#scfn0?{lEUEosj4L~AbsSGVIk{9?>m(-zi=-6lLK+PoE2x|AkiY8iH)YWLWI-;4L3PUmzZs+s)3y0r&(^+NpK5S@ ztefsqb}HWKz@3P(Rn@Rb6kRQ*aPs;Bwd}h=lN;xCCWZKOr^{r$=Zb1hTVSw=6_?)Z<~$}Yom*$nM4-`d!$ zg};{)FR5B1RvQ@Yz#=T1|5t@Uy2>19KA*d$*<2XrXVq>d`^bdah&Na-XO!B0cP9W{ z*Kj70oL1Kk(EHT1H#)8MvI4bq+fcIB*LUqIjILaT5XeLip>(I2q_scb}( z80RH)G@<32k#x6SZ!jKcM&3EcQ{L0x{=UjrI!&ilFMQRBicvrrh_FV3D`3bs{U}QM z5XdlWgg!xk0F08g-W*{<)rL#(OMmH#eT&Fcv{Xd~ zikcTer46#hrQ38>-zyPJ4}C9+C4I{V{a=kC&1w?~iqFp&@f7RAh>UI5P3)D&9 zc8W)5+%uq02&9h-08KIp3+uatG_*^_*c(QEM@hY+rFI$Oek9^&8{4ya1L4%CxX>SH zdkg~z^u@MuA^&S@niEkp#;++by|-BAuuOD zg36y#;Elq{J~BLP0kmSQZ2ykH!b2EsYu)g(dEc^+YK`|C=ByF?sqz{93wVU;)$|^5 z=mhkGKm1Xr{(C(Tn|uO9Cl#Wf;uv4`Q4;ZLWcm4;0Q|!F3pwP(0-nPP?ZW2f6zfE$ zU^;QJ4x&KD5uM2o|6#W3Q%cXE-3w_dFA3lgWAa}Ph2jVou!>2EO0Br_U+!VRtzGC9 z8@9}^W1JNwoIhS9nunBXARz`lfByXa7TzCFYz`9mfvvJCz)DI+55B^sa?vm{${RKA zhlokm2ri{M6y@=M0f-sRO@@h53tcDMdP0?xEys*Ox!f6*S*?)uw?g^!IUqj))cLP^ zWkaW=C66u+4cdz21JU`Wf(26OIr#eOz=(1XR8mAcqhdn*e?2EFuPppg5IA+` zee{c=5W}F1ApodU9w5C%6Fi@JN9W?wTivxxA6Z+Po`U3sLappGNviG_4|o_~Fk#Gq z=!QZ7zK|J7LjZ%HKtyXoL?NYInAGtI9Q>w1C+N;+m;(Rx@e72o?{8X`cyjTpw_bKW zkhju<#RH-29Zsb>Q{`bmh>9l1QKaI6L2`26>NU!RF>E6KW&`59k7< zQM&(&7?Vgjo-JVd=xrzS3OV7QG!iju+=G*&EGd(V#z)`|#6bowN1=dFl?4r0t=(*v zG^M-5P(b{>SGYtOZXX2_24O_rxY8jzM~Gf3P@~fyLck@kWx(ZgT0z62;ET-pIN^%T zbZ2}2^F98)s0CmyK)GI>5yTIE3f{Jalq0CFz>g+NU5$tu^OPIhe{PTytz3{3f%{OT zxXM=oLU2ef8K^M;npiIGk_FUFuYX;InR0J%QTkcE@rb#wS*ig_3o=gQPlXHEu67&X z_kNM42AU^mzX@33YD7*YU`VQNGaXzUhzmp<3r}(96eLvW*}((gpo}`*90`=r zdF0Xedm`aO-%TvQO($a(>QvC(+?0VpW@GTEGuE5?^Xg}`8`=5b0`OM;i|K20kzc>Q z0^=z;Ao*83{!*wB+92-}*zwo_JRl;3os+Q;x-~F>57tHC8`|6dR5aLbk>N|zw3rG9 zwf?G%Z4zL2dr?d#2_OeNki*e}1TZMQPELF0o^fmf%C)XvWC z)s+V!n6no`5yM>o>iq;cjF-@_ec&gEE~8ngEjqtcy|uzmi7HABh^O+`y#X4#?hm%D6NcD6_Yh1MZpmX&;ye+#!dQ2S}s%xV5W5VbmTQ8%rlp^?JHrt;UiC^oa@* z@CJh1+2fI%kJ&_1iU5~LYk0pYmX$~V?Toub4-$~LJ5IkZaH=v(7ga0T$5l6>Mggx3 z*ldbp>iqXCW{}8aP_@QTvI-}9g@^}4sg+=m&ikwl^?2v^_5yJ8u>@K(cjv`_L9T$7 z!{f}R)APGGP3GvF;FIN;Q%2pU$XfV{-#_Zoux%4IBW+(Sf7 z$xO`)9FfAQIuLSjd;ERti0;d~DSv0L&xse)x4=agKko|#na5L8Ka+LwxP5bUuyX$e z6q8|Wq_J^vrsz@*_((Eujakw``-KNlGTOkWhLWWjAqg?0tRCcoba|wN#5!N)=A-x;wszz8y zRp|@4(>WygGB!c!w{hV3!vdHAxN5q7dWnL%J{D#IN^13D`q{b^iS_04^5!`J0D$WV z1Iw`3-@Je?Bw%5SLBj`l2fH)%FMvYR-!@apkoxp`!5V|TsLnhmWO4t4jTF}Kybw}ZZI%}6;^owoD}rt?=W0MI4lccPmG)-iML5Nio##_w9? zANAc=i69McaeA5$v4lO7a+X}HI zZEkMVq)QsI$hhT43YC|8AmF9oVfEj!m3mc*F%=ALG`z(b4wKV;9=KcJPUB0xhA-x&$he z4Iy_PI;ZEo+~vuY$5pkz*y+?11B5XN(3oDwUNxN)1*Z&H{RLcx%uP&-(ziz_Y~V$b zqXpgP16SP)AU04jGs|Pf)DgO*Ro~}_kC6t6!dYOGn?*#g{vbkGYMd6AF4x6P+DZFx z$*9;{oC(A%rdly z;KTK9t$OLq&7pb0USw2Zw-FfO09Bwtv5YY#^j($_`vtrnbS3Ow&BIa3X%kzdVB(^t zmdlXX{qSBZ$6aPI*B#icpaLC!YN6UUS^;*b(vYcL7P!5;W(9Y*w5`_+wPzzNJ=Eeym5pCy;D3Xki&iuiM(=i7k7pLnUNQd~#DUqsj2Yr$A4DnI&;KEd0isMW-H`yoRwvOSVsSp+AiB3n12KARcl;a$=-hJc#lM_fbf}4@5hP}reX58Ir_Qe3G72) z*}~@iR3pv-V_`MUg3qvr5_O~)Ec4S`5$?<3WX3G6dY5vN9sO8q#KxeI$uepf>~+rh zoWLi1Sw{5DX7Bay>F2c3fBwYp?CtpMRpJ<}3z_#{VolKPr@1SEUCll%uRGG2G^3J} zXnEG;Uv$J05@*PNuX7<@(-9slS{UXzba9Y>j3hNKwbT$7u&-%_`jnS2cD^*=;Uk!< zt7~;1sWbvcqBeYdKb6NeY0yG=2ol^4`4cGAd?b4eVm zGWF3;`}(W*b|-FU>YsQm7Za7^{^-z~S4Oi_FE8=G)o=3MVM&N&!LJYK*t=(N!hHUM zhM>NSqJ%Q--~h+h9G=05Nd%iO+d9C;@;3<6IVdFx_U*A~_t4QCB6~rsI?c5qfNN@+ zt98~pl7!z1NINM|)q3CGx_$}_#o>1cm#xkm85M z$rCAu)p{NFE)_UC%xP@gA4eGN60Lc7$fhH8<8r1o?&o_i{qgL$`>63=tMhvQ+$$gmomK4cZSvc*NU`yEx=CwW;pUy zo^sZmx74D^@QJ~-vIrZ!~08GR*y@|sEcCHyq8*?EIb8teA`iI< z5D^j8YmJ~gYK1vD?}O3H3_b*HVZ#&NW2{_cLjPUC3^h9LXq+9<;qfq;zxBwP;grKo z_+#VfVEOlxhBpkOdy_<*kL~>Ad$o@F>dlbj1@8;i;)acJkedKQ31RXb5vn(1vq|TX z6XW@9Ajfe6;GXqO!)O6|Z^aAo`TLLD3t_`1*@d_ZuHl8sYuw<4U~I_1Z-x(x68kxF zna1`8k=sSCWSPFKGZ{w1U#ZY|H>|se4~yAK14{%>L7r_|9_%Gi@SijnfYgO&^f0cd zj#*&F%ahaUj2$ifo33`^n{<|*-nMU8)}7Ww(KWUo^=7vj17jvTUWk2f3*81p)8V8S zYk@qv`dtOD%A@v>5>Liv*;YS6m#?J(r}G*JVj0zFki8DZ$sDcRN-wfOGgq)CJk*a>FU{s;t_$*;aQrruz{FOs}Y-Gwh0$0xvti(JL_r5dqW!us3b zfDUIC$D4?!GJjhhIX&T#7Fm&!JJRPR<9O&SiLYfxsTn7IbSIQ}KPf?IW^UPH14<AxenVwfpjvBvB^0?K3XA+l-UM^R>cPCk!PDnfw!O&REnTTAwZ_kSpYRk z8&L46XlWH}4BGc{aWy%Y8K*QO^(ajC2S;NC65oe(RI`NXs9UkW<91GQ`s~`?;y<`k z)7X*c;F#x<-{7_NjUZ9P5>`>Fr!l)$+Ln<)4X6y%qhKORa4jx=fW|PHsdMCW-ikfz z%KAK0Ry=WBN-oUzybzHm3r>W2U=qxXvc3vE`N?`XL;0}lmn8m~qn?q2GAAq{?&Y^% zgpuAVa9{dg)IZi$Q?dX(lLbglTch8y8?X?;M>9>V)%>B_=(D}3sNWLY-hJf}7Zs-t zXFnxuB?jBW=MMX~fftI0_Z7-FaGZPRdNui{+nZSVyN0u9^@N>puO;8O)kme8!*OAq z=lF8_k_$wHU+O5mB)^7*^(ZNgaCP3?ctT#Lj0C@KpS@{-kKe3<^U8?1vbt&n_Mrse zT#pP_-QMq&&fQu|8D4aQeRiW5>!JuAcnh)y9`&je3{aSw#&y%7^P1Nn3kggYq10*q zz0}Ib6z0GFSROvQdvlx1;9zrUJVubvm|dz{iw*QN%CiA}-~rlVeAlV&E&HiS-xV9K zkE7MAjlgnmuut_Qc`4NX<>{Dj4+^^X#*3gQHhBbC$T01PZ!3S_A9(ghd$a^ zup!WU6+a!=ehsBXafI@$71VN?e#YF)-yhs-`z{p!ebFumUr!P}_EQ7Kbgcxy$JPy$ z@>&ckY^2MKhW;?8)jLqWs>i*z;4);qYPun2O&zVI8yQ=cF7rBjn4;CJr>adZ7ye4g zD{$O8ND)Uc{AFsR_KCmW@RBizb*2bT#63MdZNS7Q z5R6IAY-nq|b0YSL6%46a-mBf-ebv1xr?8%dDy0GUzQ8D<=}ui{vZBCBcR zH6c-6|Kpvkk)RSnDB4P%erHN~yJT~RT;X*o@G@VKjT}_o-q|Yoknu3*0rDNq!L)tr zA~0W*j&jp8*bLg9Lq)th&YZPtdABQ1TjP0lm;OsX_t#O=FPMt00PW)8I*sN7-5p$| zZ#y@4ayA#rf-+BAc-A6|NLSZ#goTB>xXj5hHe}(f%0IxY*hj!lQzSh5cCURPg_J(H zwIOoqB~fMZZkg#W73T5;eA2Ts%-FHeCq0JTBsr{U$6I{SWYRj3PtGGza8pYJBSdJo7zFdJMgXXpv27?y1I|Faodr@2BcY_Bx^S=|1u!=f&GZ#>U1FI3Zp< zzdgS`LFhmS*~u9X=ez<2)Sq9Cl)RI3J#k-UQ`r_6%<-N~gpCs`-*nFYMO-=kFQ=Iy zkp)K;T(#hlO$?f$^!f4S!DVKzXzP!I^Ts?O0&x^EF4^kRbp1`=LZI>AH1ZG9%u!=_GT7;P4{ ztB#^x?d#L(=;-J&;DtZqGHvVN=%_Lq4nA8rc%ss-2EZ?=>10^0@EO@`2?2^yGJ>Wn zj{0E@{(GYVY*x=DeG1)jQq!lMHP2ZCA4{v)VTV66_|#<06l60?*UQkU4lRC5 z`j4@8RrMw|B%;uKtZ@Y&#G?5R$d{XzH5_!|i!c3Hq;VEkl89&zr?K@#e@vuu4kYxK z*cHqlYt(&kU!3W;l^VNLP*MtH^`h0hciEeHq@Tr3NjR9?1}ag(9@l7#r&B6Sk}=MZ9MsFMyXhqvlPah6yz?D+@J&wiEY6mSm{f8<~>v z=%HQHtu@;~o+3A_Bd$yf1K_~uC#-s}9dOH`F&vX%3ajha*k zyKXbWwYTKXVW8zV0j~IH4NSBNxMxGLOSxPSYJ=(ZLoxZb97G#aEz}^FuxRr z(Ttd79cJJq$$tF$UWF$QPK3d+srz}di4aN}0>rqj3~C-qU*_LfVuztTly+M-gJPE- zGf|M$2!i|Ogu`l&=-7nmk|sA1B9oz`9~kJ$271Lvmc~&EtPkfTjU_%0NCc*;6vo45 zIXzI~TW+{8p1&BCkK)uIW@KUd@oVh2Wa{c`iE1nRsO}$L)}KP1aKy1zDTn*ho?CT+ z!Tf;kIiM8`2hJH;K>Hze+Ac*{R?`NyT^{tZlo|qo>tv&AsU@&)@U0>fLO`6m!L6Vm z#lhH6_kCs1Jg40s3WYCsWMhfp^GDoodb_jVK31nBEMo$FmX~~YNl(LjJSK+lLdqoh zn_jY3r_RzMc>&xeOOc%@~>|L+h)x3pNbS^_UgU%U9}OP2O)u zND1}sx7~Bu7;llB?MRp%A&Wm&zD%sE$Ekna>|N@8fQ?SDN?DWjy{e-?4!B<7L9TV+ zPz3^$iJub_VE;MDDC%sHsi|RLt5Uaeuc)^OBO4pHCEAa2XRIQR)pHDUlH-Xr9!m!s zd4v~GUNKX40TrJzJRepYlv7$L){L&yWT+W2ymjL3sKbkgdmkXV8MP-PlKyG5gMc@< z?ot;hW+xk*i!!@P8ylC5&y!t$x`Mda2XpeL)NX`U?L`Vy7V-ZLYowY@^Bl6{A&4vD zU7zzIdG`2kzS)N_J&Inv}GhtI`iLppU4{3N}{ApJ_Lt-^1ra z{4IZ!!5g@Xgo3Vsd@J~ahlj51O%n$)g(UpW=^ljfrPB!m>934l8EcVLFKb%YX+j0f zO4qJ*O#1id!rgJgB<(^D(FKbq-Zrfw4G*OL7&8e}DeW#MF2dS+gnKdnH@iZy`Nps# zQJmg$qSPP)Xy|&&e(3`0w$AYzXQYh@o?l7|1*9_yc)Je_9%*7)^C+W={~B#v8)%)} zr{{%7?J^mngRsYz*((g))P_9RDZ7clnM*G_5}h0lRl5Ifs}Oc{INgBW9O-`8q;=?_ zcd8W~6H^Y9aSti@aDmFXaGEm`MmR`M&AYh?_6}xZe@lvYI@P%6o%8opo6rv@$Rgg! z%BO^v7u5EOi@+W&y^6Lr$CotzRyxVmcauw*6MI8RPoU*AqE{~_xUW+<*EP&Xl`*o%jC_PcGbXGcE`|qvoO$Mue74q{IO^$ zO0PO_Mq&jxEsI7_3MpqxVf}}W6cBj6n(x=Oy2uV z7tg2iFeg7>yrdvSz_ob>8%&LY<@X3TgFj%FvtywI8R z=0$0~-ApH!*FCro%?pnMukID}>c0JM`SO(;`UuCcyn zr`9y;oEv`pdFrci^{DsQaU6L_kGe_)u`yVBqE-tcJEL+r{MD;qBh51~;c0N1-Q+jK zNqRYTUND!T()oCL@@u;sv0-Ox`f7kJkBh+{p%fNr?4Pp}INZrq9Ig{S+e+TPkA^CW z3r;AQ^~AM4bE%-oVmda{-9`fznh$_foRwZNB?v4Z z$t)=7VMPqKnpUwHJ$pokMM{76Xi@*6nW!m)3yWcW1m$l0C&YYEq{98kk;pJQ2sugtBC~e-)*dP@P8c1MWv=?wUzEQh$8H<@Ee9b? zhVNh=>)wIf`!~AB7)`)t8Q;F;lAiZRdA&d{!j!)+Yl0J4zacvAq~=jY>dw28XRGK^ z1v{MSFTOa9I%FhZNpj~4_jPq(?7BG-Xeum=w-D7p;qiL;_}b@I<@mfZWbaEv1P;(8 zn#(+x0EBH8M~jjl2J#B?5h%3rL16qeLotyO$a*}oXn>PF6*Ls!#IUxL?4{B+hn#p) zeJ;>*s0uYYWxkVx%iQGxC% zvMghAOCv=J8~R&Q)5Rfas!rZ{&+HFc6)u($>vwEhiF0`kVQfgfVa?tYKI9nhDr}AT zD8z{{M>qcxh|i^}tg_&!DpI!pbt)b)Q_i48IJ`y# zv%VHGXT3NV7?_wW7~ET4pc;j;t!>vFrYn^}_LboijEy%?!kstjkAMO~FpPFnK=WoW z?}FULX5EhT>W+k7Kdg_*(8~|g!*!{afy!5pEqAp*votM}*tk95yLEAASa~=@8JJCP zFZ=JIPAiIG{I``uHST=lN-+| zj-Aj)+c8WOcXUO}NnO=fTIm#o2Zw}!{i*~UKm!C?&O*Cx_${Vh!+Z33pL-!Nwfq?f zG84Ui9x+AaiSNT3(Q(=s)Ev_-D-F^gzivwdh)i{UV`url_Vc5!)|jJ=o`z#WO3jk$ znJ%S!7siv7r%Z<4ekVCEv<@y>x$dKP0Qd%G&B=iKC-;5Eziiv_D%vcos+&~a zGrImB5tWdZmlwQ6T7Ojw6pf@{g$q~<_K-dP^0jS#ep^$MAXt4s3eDzeeAn`;sCZtf zq%{7^Zqx_uuaYO}5xO{pf>eOdqgUz+_&69BdSJj|{ZbEMA@9@RdR*AH*Uh~OHasWI%E4pHgSHxX$T>e-G zTPryMhYIQr|9Ql*ktx%+x#q46018aSIrtTe`;{p7z^&S2uRsjr|Gb3}>U(lrBy_dwJHEoc$;ZnCB(F zFvq6y7-Q{5`G6luUL|z==S4tLxX#*B&C|2w=U79#FRd-jUX)KLGAuPVqF9#T*u5ZF zfd6^6K({ronAqA&uff=h!|m#I{q*e5Epeyu=e(!k3k!zrs_f6vXPP}+>3!M!Y(5K{ zWXLs`2X6c@HA|lK{wM`#7RS5!}j{i1wt=gD=iY2Pul%i{pdbNn0`QT+!yH>3B2d91Wvf_tzcYnVw{j}J^2D-vJ` z>UK}%ss>U{uU1d{i^!h%u8#7_0AEk}+ce~w^1tUF@ah*A=IZB-_6O!^*?0e*=Kr~? zX7z}Tj5L&io$pSQ0LIj;6-9tc0q$*)keC<*^fX4B!?^)*%}$?7b8+g8%tNQUv!)JI z^kj@JZO-;NK-u~7Pw|=rl;$l+9Gsl4bZ!=SHNKg?Epj2{SG``|m%Tj_M7B|Y+xb?W zbTUL%dBv<{c6N7@yHwiEHPx@x*Z>j$H?rtQo?>bUD4j&DKT_u?rfAIyJkC5N``fO@ zLSf=`QGWIm3y>be?0Jp_I*}Kn8*$|2mW8c$OAhVi+~T1sCMvDwB0V_wZh(VXBLH8A zXZ%hu6n^)RI+zXWJ!_kXJ60Hugcy`mpd7Lx1{ZEp>i-MPg3rhCr1x_WDCYyTnZ`BYfFA2|+& z8r{O)cHnh_yWJ;`3Fi&qd-{KX?;#ss6dHP}dd@Ew@$-bDIi8raf`d(A`GCIfRw%d8i zi#!mxGBn^To<+GKhMiAIK+8i-+5Nb*#sKg`B(QZ?`|ja{GI8f{{@OvCqyJH1EC}`J{k^>UZNI90EYcE^lgafx4v2*E7M%f5rMEjVyh;xZ%>m|ln@Cs;v8PPjj_5w2ZeKHBnRM$g~j~#X* z<{dX`>t%$?Cag?ilH027X8)GaYL#HbKaByFXsBQ*Vo}(y_-A@LIvAJp=Jiglpu3uE z_NZMP_|Cc=mlCDq*x|hI-{o)b&x&q7z&&ldssE0ZP#FRW$)0vW^reHmpCavMQK7?T z$Nc!uR*f7JX2)?t+z4?>JuV#Ud<9WQ{WLS2`VdQ?8w`k(Pa>~GKrYmR$J@Kzo=zNg zk{H10NvbGmoVT;j#Xu;?KI-z-;+rb z4|GSFVut;l8J+p=6f+8tBFOzcKxEa*@bVmtv254GMt@KC26lNA0xl1M0TAc+m04iK zu(*$mjJ!tAQz)WcL*w11%(Wr(fYSR;>hy;c_qWxnk^z9R0CZ}BB66~fd$`;lm})De6EoX`A0dN4_<#6o6ej*O1}2%27U zuzU>t%PYp7IQ`vyFC^Q&q=MgJA8MeYI)nrVqksr7LDB^i#~Rg(JdYTx@6+Zh5xe8r zfBxe3$)ILw&+FP`-sU0?7r2~msi%ne zPy#{ji>7|i6@&F|?ex}8*#I8IPdfEQ};(Nq722GcVS_^C1Y5bXFLTme)15$TRHQzAYgm`KDOL z=Agjf_U8#_P7;1}?cX|fS1z8b`!ag1z09XR)yBP8>R?#I4~#f``uzD(blZ_qy}O>W zGA;}Td&n*#B64wY(KbCzuCK4};OffT^wu&jWDE0mPO)B?u9lX?MTY#{`Eh9iYcW-$Jw@?=H)J_DeFM+p|pWu_;_#Vfrv4Y@F3G!&kQsL@d&$o;`5~PRq5bqw)!!vuaqH}w*=yvYP3ap2@NG^D0Hglf4;^q>@b?)tvch}RK9 z>DRnTVRfnyyR%RRtLRz;1%0RAXAyB$#Ni!kDk^B*>mk@&PeVs1@y-Jf*kpc>1zbp@ zo54^lORvONe3J+iFVw8xPX^j*Kh%}KnN%03&vKM1tzrE0tER~DU0Dr{!}^W^?p)4i z+lP4}Qc>=Qg8eqy5tY+Ci#PqnjJ+>PY~+csCNkNd+zU{Sl(Sg5)*%COWwa?ETLtCp=O(AsFZSW3d-`v(yEdVP6pkp~@^ zk&V>?!;Xq{#oq1<}=KB?WInG}3lC!v9^MRBfddj|iQAsU}636W6``B`GB=;wU zf28b16BDWEQ2EBqfoenPv~JW;SH%2}@KSS)_euMz8L$2NafE_hmZ=K3x}p^035lsM zfu3Qek>J<$c~AweM;-p;$NL8>1N+>`)z4#XgDi$nM5|3y`hNZTsQGpR5E$wJ>(m`v zYNGtF+9w4cp#clln6{wH-A11O5~_rGu5ucHE=mX(iOEfNZ_eKBTzM0RU>=nUWZ%hs zz3~G1{;&-Z2VMeO6ZPvwT0s#f=e6qq6Kp8jKSlSL?R#mcu6GYg1BWaY(0X(t!av+_ zxbQuDJhxvkvfiXXXz}j6b#QgaTwzDDwa-*&!P;DLarj3wc&F6QsQ7(HJC+$)*v9sA z*tlM}8ieo z(Rz#AcM~G9Ki$$`gavSFhE^cRTojie*YACVq*jhdhc6ywcb&W#C#4uX)&wmmNX$YH@zZ#51nwb55!L;= zsypjgn-49a10NJo_Tu0Dgd$2Tvc#}!xXcyvUO(|P6N0$N#p`-;DG(&%Fy89%nakW! zYG0AB^wOJP10MFvqZcTkjo{mwD76PPQ8s}eAhB7sE0CZ@H6GykuU~u*ImW<{O6EK# z3UZy*Bf&BP9vEOX zF|ByhFgu;79YuaOju{a4;Nm`dsy~Dl5+a(R1=cXXK$7bAy$|F0-OB7DhVTZhZo`;3 z1qnJz7>Vj|I!}|a_Z#dQ_z;XGPZC+=bk%aj^H;$Qce?coNjm=7aLNrEBV!v7gxI;A zZcpGu2p*vU?`Y&hn($4;w>UiP)C7&dr>Gm;Vvzgs8qJFrBdjK8=tXuiXFQLdM+bi* zmjJj*N{qk`ITiRl%ul6S^631r z3X3zoi{}f1z|K7TuAR3nV2HOz6R7$efgK&Bl|aM57#7L!?^CA#z-#;Wt;|vH%VsEb zCIirhemkt|oC?ESQ?v54C?BjRNE;t{&qu<7<`%RvcgOei>J4sz#W9WT+!w>BnCe5V=clUM$Iy=*&@!4rq$}F{*d>?8-i7@ZIIF+f? zC4Nv(hd%f%U@>IO3ccrU+ylbox^Fsr92>hzxD(A0CIt@hgk>Lnzh5?<=i8Ur&*3a3 z3{YfCFFf=9MIr3TM=zh~`{@%>$sM=O@D&y&CMM*Q%&J#6cmT|p3bj$yD=jgr$h>ft z8cx@q?O{}4rq|)*^L*C`TC&7YV;#Ki5DY>tqhS-=NRbB+%IMeLci7rXhiawLCuyQEPWY+O)n zFrH?{k4!EHoz$PQ4<117-b2hX_{Y6R-SqUsi6rRBsbg9MN6|ffCJgoXU9{eCWHUMt z_(i=~r>;||0FuBR91->pWPiOt1AqiAOS9)wEa0ZVMQ@|!r0_BK3jg^rWE%RX0zm-`sc?WL zzz@lJ9&&PWj(?5y7r*`fhWwtxJo0a3c`jm=W0KS<=@8Vwb}A?y40s|AW| z8*m^TTL-D#R#x>}@*t-Fd}=0P*D*+@L>N6|tD}K&D--zJuet(&+7% zKP52$AnRBgAI_oPKO_d@7HeNv-L%}8of^Zc_Ba=xi4<61(s*$E-=oAx6bJb5JH`r} zf1d+BZti23kj*NMq}RU%x-zC!+5bu07!8pj?M=#56Qy**eE=vY#Y<$lCVdfxstV0#vaNeJBOpaDk0$=@I|0xF)P?EK$$a9|!2g z`l}PJpD=rI)P;95FN%lp82O~1AgzoLsBl}~F1MyCK6_R$D^BMV81Ye4CJ|4l68&G6 zP+stX-Jsw45Z^!q*8}8FzA|%qcs`7Gc~J)!#}?clV13|`laoZQQYn_a2_`6tUjiCk zvrG>jzsnNvAOAZM%3mM&kbRdAT!&5sUz%^D%oTCL*RzaT5mCK!VJopm z;-D-Ef{SY8X>d-ecPTXN)#SM(tcTl&wK>{5aY<6WOI65~Do9zH%yn^vslrelle@`y-a*2BYtTm#d?&5cy!*DtzqdHQ?j*pN?e zg58h|#wS1x0|(vs;!}b~CsVr*9~u_p03-qYf))VY6S;14=|J==t-vJ}7qmIX0t`^Y z#D+5=+)a;P`zT+D*0K*nZ>EX(v$14T((=;kZ^oPZxK^traDp!9i+T={3Nyi@@* zkJ3;9tS92P)Fh}k*+>9Ty!OjC(+;bXc(Dm_TlQHzFQ3#W)w*2w2iZ+L;!dvp7@FJ| z7Czb%sLrg>m`<{)s937?>CcPcB!-xzw1DqBHhsI3ZH~yW+grR114lN4>gO$ZLl)2X zyw&WdG;9gFRQ`JrK;W|N%bThSfr`q(ZNF(ZuXUcTW90))mBYYK?*C%?h6LOWCBf5y zJ^FmNOHyU}&MDXB(4uTgDQ>DJtqJzn6ImgRFEXT0(2IB6EW$idd`B zH$kswPSgVL)T=V>hZ7PACau}YXM{)!W`yDW*h3~L^Ko+A!d#nAeRpP2r z&QbJecvy8byTb(Kju_@dP&o+_Jhz{|y&titv4HuRNRF#+$=xWer+|){^8q2HCMoyp zs7f~v!s1h-XMV%x81$s15=%uB2_beRHXbv0eDxNo>QVKh@=JD|%1eJ&bgxMoG8=`_ zbmAQ-l4?FQdd&*EET9S{*P?7x(46nMXL`O$qvi>^DNgTa}3@u&*?~^zs zDF^vY@8@Ws8pwM!E9k8*l-Gx)@{K9=CBc!7y zA@m~Dp;pz7FmL0+%r8Wh1?^M&Pto-e39xe7`yg0V6Kv>ZJi! z!w$!ZSo^mOVX$a{R06>an0z_IAD#e_Jq}i2s>ZrR&SYy>f0#`;_N|b6mENe~^a^j4 zK4}$eaDru|o!I`HCq4%Qg{id6gy`QNF#PFxm6~0v!4T!tnigPItfs8~R?Qm}AAcbt zeQ7BDC*X!FUxSdc&T%_~)1JdU^IBXERN|T|sNYF@m2ErLfC(-9r(CnW8 zs{thmiQn%u@ZWYMbo*|@$O95940_Zk>MChJe;isnDjU_0ylu|wP|!%z8q$d=YzQ|? zgJ*jD=ERm`EdYVav!5MCWLezUJZ?#g%)A?Gv_-?C|X zZXz&Bd7xPBwJQl60Ofpw(H4May%wV{^zs6SAjB*?Ge;X*3n9${^HCwLs_`Oq^18rX z3b0%j5@?m~F0fpGn5unyYJdsXQ|MY#Q2nWp!z%m!V@g5C{;60aFDItHA~}|)mka8C z=;svNzP|Kq3+dLX?>FFs;^hs+iVl@RCIVNURi`J@F2P>NN)8k%;A!#}56wtC|rmzQ^h5To>C%3%)|2O#UY~~+F6{y8jHKU{@EzA&BdWn^!YxQ}D!RChj!5SKepl9xe>^Sl*)9{hdH;OmUaLMA&C0F@9mPlICM! zO>fE3H~D;ozSDR1W(KOe#* z);`YsO0Dv0Re>_PfFXbuHKn=zZOgHv8KvesUdvSq7OyedaCSnkwBvtuE1%zu_ZNG- zDrAg>pb|6>pD)7WCv-7!pm_t^cdFtuzphL-&OP;S{dT$W#6!@FaA`D?Qs+Hk=hx^Y zF_XpROo!%*8@?|>g>EM}3%k%8m14Z3qX%pM{{1V@q|NrzQ(!x(RPtXi0$lt`z`)iX zgf6_eo4|&$SDquW75B?!w_tm9I)}he>h8O6?ASq@uEfG%;_u&~H#j|-+gb~c(4mf) zJXe+-_v^}0cdh<~tT$}v=gCb*61~!&f_=7>){mNw7ZT^l~3VHcD;zC*Sk8b7W zjZ%+(y%AixV~uG^u@GxCsi36w-ptv;dJ(moVx-ObS(3P_ZExQC?w*}Qq1>LyB-b4Y zxyN)Ybfc>>x*&_IirBF9IM(NLKy(OA4jnsfyt0BFk@(r{>%AQFT9DFzF++O8+kuGtLZ^!+MWQ?89`S{~ia9f7@yYQaIaeo5aLEW}GUr3(2a|~y*cF9Ob z)>+aSqVoMzXXaDSW-V6qHK0Svmt6xORQlQ&C?QPohC`kYhl9>JLw$H>nudEJ{>D!u z?eHn8*mL!ya2FV5%3bBWU&M?v}+*adOzW99+q@AZyV)Jl)o3yhY?1f6)w zaC}4*ahHVU!>gZzQH1l|nCAYA@y_G#v z$E^kP2wV3JyRm&Is3IcK{nuwCWMq2BN}gS5cHbh$BMYA!qK;QiKNa0H~%YAq+ z?c$+S`gu?`fnTpqJ;h}W4~IMB-Hey$cTeVee5h)P|@1DEp%!I`7Dh(J_FTe%GlDZ;(D{qIzp?Z9X%#{OHy6 zv{qlf_H8a}vY^Dfy|lcA3I}@9A;C7D&1N9Fpsn&0&hTs=Zv9D8fW-E8co1F9 zCy$t|i_U15ZBA;Bzswdh1|B(B=^|s2<=!FLANb8gBm}r^Z>@_DD@^y0+_vAx+FkyW zabnpmlpaVroRIQoC@`Emr7hsTS;{A#>c6)QddHIN{}9gc&w_ zR=K+7{TI_jYDHY1BRno}qecq)W>m^{@tMtMJ)RfPETF*FLn#kzKB{6uBAe^>%qVm%I-P|^f-W&T0 zkjny0VFu*`=8s);zCxNGhIt|L2qvsb9-S9R|HM+DvHjvp3Y2^Yl!lV2ToN!wNFUJ`GT1?~VO_8E~~Z|oix8zr&pxg?DH)jX{{ zbS4;LSDdEPrI--!i%l^~`$M15CBVK)r>FU+t2xI(mU5NN zbJ?8mk2E_x$d`Em+h|Vla*W1Y^mat z;n#fayf-Rd0bxV_`LBMY2?l!uXaGr6X9jJ73VYsruxsr9LDGE&7N(Z74>aP&Q3#1% zQQwQ*?s!DvZasD0k1`@=p6&L{(+wQ2PVz>9P{PPOXTphAgCfa&+|3!RUnuZ4#~;lq z57OW}r8_;%U+aV7KB#v8evnV?N&XqLo~zR6CZNfF0iuY!o;cMsD0(VIO)G0=;;5pQ zzHPy!M+P?P|V*A_QiI`bqa0L@lCh{ z!Q_WN$DLi78>u~p0-H4}o2QT(|9nIn1%L0%!=+f`5zle`DpsoZztLT!yz%e!WYJL= zsZ#qj@#^;SKhiHkB?iwvd(~%b2(#%(H7{`27F#rXT8z1VIr6%`wmLs|OSR9tvpSH8 z;`Zs0weMIQzVPVk+3d$^dax+xc$36~SA8lLS8MfR{^1Zo2r5*C1PpBpbGJ5iYnm|p z6ZjwG5_Q9)TE&Q+;h!;ezn?%y7oqR52Z|`lk*ElAqWFJbi>r;lnxa}AX4J9g7v$<- zW@uWTZZC2=C$N}ud^2Psh%Lc*n}AgNwq@MNOU=FYzB=lK4e`d`K@B5uXFF#@)p;&1 zop|EI(NnTMhhi557!8p)fc^`1v4Tg|=-glM5ll))3U@D~_yi zKx&&vOyhRMOiOh+vvR8r%^~o7dyUzC8?$z+g{Ah`MH_vLqL`mx646D+9qI`(a^>cP z)ls!NZ)CZ0NQ1=xDV`L7^JoOeCP&0-_jY}~%eHYL#B(IEDU|TT^;`E7p^sypqFj{* z|3=0Mz_olWHR?s8q*$ZGBUtJ1J*SSt@7aT5#fOD)1!N#e2e^dG8fmA8$5Ve+X6#>J zxv|(xy1le6hh81}Q;Slr&f6d>%64J%aq~u*TKD%a2_F}t-ujOy+iO^rkvyOdohW}r zK=>eF>=S#-aKS^J`A;;UiYp+R8F1>HAwLs5 z3|E%v=nL_yUC{&1e#JZvPE1UoRb6&-srofJNF}60{a&mfOJCV4vVr#_ss}#hT*&$_ z&ur=iHXW2t+0=eTdR{YK8`j}C9$U-nq1OV>Dd$tKPV8pHW$7l)*SMA`vj;?W-?I(747faefjb=RjKakMN9N6aP02mW^|w@+7b-rV8I5~ zM+@C-Uvrbxs!v4pR}M$}*J7OU?-y1H7p;3fkVJ~M4OmI3t(66IVoXFH^`#m8;)9MK zg&NE^S*pfvbgWWN+l$y8E611URNHDaF=qu%Lud1VIQ z|40*)^7^ml7osIqhqKI`(J3x5hZf*>p`-@48_ zZS$!NZWs(1czb{JKD)MKc4!&A(Va@v+)nBr<`2HXQ#xIh2&l^O`lczJ%wY_Z<{YiQwhuKXo-qAkGkwZ{fmKj&zyZLHDgZOfDjBTe0g-G@&2&qx9qd4Cph+(?82JEL4IYQ^Ke za!Xhp?

yx!PX5n~N~6=VgwitY115B7p1^DE{p0URHt>U5Pd&SE2B3G@N*M&k{MN z0$rrz_R}6~jr1pn4IJ

TeFtz1LTXy1+I^2qjHMZ&?dFQY>W#n?<~9U~BOYo4E2S ztgyZjp~zoo)w11O3Qd6WrHJ;x=;$oLQ$7jg&_$7rd#n7O@OVQ%r7-j-bIeDq-vjqcIAvqKY#f}cs$ZC!HS1w1 zq?n;S0jfE9R-pnb28(yTvq6jDJ?Q8%_|gB|a4d30ag@@f(RELzZw6KN-LRMI_cX{xSr+KgOxxpR|9|6jFvwD>8; z_qTtpMmPx4{7VVFl>6VesbMLVdzY3H&bS&sN;AKcS6i07 zR)4(ctf-;&gEdL=A<9gT0o!Y@r{sYV&eY4C#~GgIw*ES~(%_2Z{@zs$4GN3MkjCw}ZO< z9yPZ@%=rLOd|YjJfqbn(D=O!&=&vqs29+gnOz=pHKSqK@qaF>aV_MVhezUI<9-`%A za-=(>x}yNpZ2+u*`B1L7j1nj`k@=kv?_6cLT?xd4Soa>N8w*i3r%y=yh-uAN022WS zvz#0pU+}{X?0b?X0S^T=vdJaYr7&?lo{1_yy+MFR7Z65-RT?0J3O9Vc0aZNb&-O@1 zkd6v=JWR;dU)7#r9KJic6`bk$;0;)A=*(50pV6g>N9JN{&lICG1|o8Tjqo9{W0BDV znEem5v7zQ-AJy1q6zXh02R#XZ%1EQx73zo^517i1k`LuqXSzUate^vVi-4R6JkfHq zVI`dPUFYQ6@9lYSHe&T20P9nX~c6EJBK=hmER=*zV)HN5h8a zU`CvEGuz4gCImG8ljr5i+}VfG?Y6F?Jj%uQpvX-GWeXUl8Lt60r220673v3KxnV~R zlh2X+OkYVQ#~UOD#jFT0b=3!<1-#xYYDD;2P#>+Xax@>0HLmJ;Zq$3^RR6z$DhE^r zGFR4rlZUv~>?L4BuL{BXBvJ7WZ)emYh}Cl4Jop39aJ_g`A)RfP_a4^DzB0nDL^%?; z+5h343D6hSc+zxermM0d!_vCEY}QKl3zhfkr#*TCArsF)D`C?XQFQv6xjo4p(ofV2 z?6A=Z{mj`i_=#<~5)QlVUSaM6*-d_80_}vNj)zSgg5bV*Z*W>v>u+A=WX~z2Cq+L+ z+JX$?Sn=O6zdl-=i+f=VQAdnY{e5zgN%dT3rkwaRCr9N0J)1NToJcQV+xqFYofV|F zM9}txQvRWlpf0*(5~SWD;D%_!1fkQ)2Ik8QbzjL0d?>57`6fj+nyk#6JdbZ3;K!qU z?E9-O^UV zIlMR%u1bw(V!Q)cfC3vB#*JAVLWjO|@5(r--*L#=0q7-W=p}3k?D3@{C(H zaWn|yU*?1UAeOWI=auic$CfN9lv_D&Y<~zWKl6Hmkmz#A)VPo9R$rPx3TXv1=2C{% zu!E5>jr_@ZacJZdt~>7s`Sa_a0=WzZly7;9IUE3hMR3f2W7(;#!mXp*Vq)pG zR`vh#LFHaxASN)7G6!;+-d5;Ck74yn{%a&bgy~50Izknze!lK2W|-2D8z{$h^czyp z3bYqx*|(i}Nn2kUCGzK!HQ(rf0)!PW=*5vxmgk*2TUI@wT3DHUfLvpF`n(U5($%#V zl6`+w+epo*r-@2!RbcXfCW9}#PRa7GJ`ukm@|AZ<0L=PW3uQskjtEA!^jS^S0jb@U z`As6g5jDgAbRG3tSo(=Cv`RT6H6hT|w2eYv8znLzgShOCx(*hb$}24l(6bw{g0f_P zYxBFpgOxg@6dM1a8nr%-b91nD8o9AJA@ucjB~?lnMIK(ecdy?g$%p|KlI!=L43aSw zR!4?%XY255{4W00-EDhhO62Jyg>XCUqd!(ao83u1Vd1Gz!bkSmgQ+-?eEpw$#GoGY zXkWYF(DB5J84ZHs6rGf~3SACqX_?{UZb`sWD|9ozGsf_GP#Vt>fP!=;DRLr{@`meY zhRDJnt0 z3!{}BTIyW`A>W1$Ux@I=zk@OYHoo4II9`Rjg{;(59r>>)lOT#9i|2DUJ5Tx?t-4!@ zxB!TkBJTq(A^z3grJWqT_!DB?SPIZz9+!7~k08z}KT2`SC@jrezpx?3NsPS_k0!Cr z5u%@~tSnSOb`OeHHdPy^^Y^zN<=PrZoYz-VJ_l%E4|qE<#WL>=4XX6r`z#K=treqa z;|VBP{mqftfyAjGq$8q&ST~}AXHY<0ijYC#Q_$? za0_LTqqkUJor{JwJ8Yph6bYgf&aun-cJ{D{h?B?&n7o2cZ7I>RH53heu01_fc1xm% zM)}`nZH=1iJYhSZ?&MZ`oPY;uPY9+I(UsU{k$B~KgI9miF47FR9pTqwuKyuIMD_vctj1>qFGn~O=6Z*L z4SMr*-hgIEt_tX=xl!;ERs(MO6(%xVwwv^U8v#Z(VU{VA)bCM9;5RP+{63%4`rC}# zUxn@6Mnki?0{(AKck=Vq)~s31+%OsxZ_zTa>KqVPZ1U$<<4^!iK+vU>rOopGgT=a? z>nTv4EM@-#n*2`&pghETuBH&rRY+`&-fo3rsMNAaouY)!|VJQ#`Y?J~B1}3e?AhKal-B z^GW>cfy$!ne}hcK!y;4pv;`4gavqgXeaP}qu(H8gK((Hj4$`JoY0y|eYWTojjy2b= zxu#^^5c-Bfqk~ToNj|q@RJ^WVx4WDfGGuQQLIZo5nS6akZQcJZbx<3qY~2b_1%Mx+ zg7VMNR_$R=Yq!#QkDHNR`>c0QehL#QzpN=X=@ud^>|6$t%EvTZA<+wIy8A+i+x%Ca zTU9*zH1-gy18$2B$}B&HXk7I8_e4#td!B;}U;JWO8w0!JR)iH$YbE=df&{d2sa^uL^H{4KBO(dAYX z}l#2)6iSrfa}tRbT|pIoBs;hd1=`*^}n$9)!}I#tnC{!+TI0FJu&faLtb$@i`p zC(u{_uv7F)<5juKT>i@3yxM@Cqa z{>YdSn0vDG~8 zuC#EgDwpBci#6qaKlO>s{7x=e0=$Zo8taH_Z4Q-67H+NIBP;k%n8PjyoW|nR7&7#t zMpJ^}%`uS+PMP)iKvX)Szdyrm2|5){`B5ZtBvPQ$)kJDPy4YV_uT-g@Vv0J-X;&m; zQCEJ@r@qPWhD+b0lQV!;__crKlN|v+ZURT$m6@vcBbX*nsCZsmzscaEI~?yF*}cL~ zWwO?<>Z0QOkBzTWU0%+{ea`N$NqkRv6Ig;}q;kV9#V9v&9SL9;JKO!qK#;rV#0hK( zxi>vl=N(^2lje@snA3W|73PTe*u6TLfw|s3)CYLAj)oN?U36}9pJ(TJ5Q{tL;pU*M zElLp<^}Ef80*_qB?o)}l&G~Zi>R1}l;qg3a4q3xCa`0Vz0)12^|-)ch(bui zVy=z8^GgD9E5^GRczJT>b|=WlH?>3r;&QC$c!8N09cXsXBlAd@K5nAo3!D353@j>g zcP_c-1_n6mPOeZd*tc}5YS^D2#)dF@b=qNWI8nDxSV|Nd$*1WXaKAe%{ae9kI<8h{ z+%W%G^@1ln&wqQ1B1N52NFOL!t2O?~#5>sIE3I-FuC*E{Yy#MmaAk|i+<0m6+z03tuIk*X4VpRsY4*besO*E=Y$zny;%$@zAA;MSZc zr$lz=5trDtpD_mB(Y3_WSp||vnjV28gqAXQiYPg^Xtz^h48PI8=O-QkoReKJFn}x_ z!AwhSUndhPBxic(7l&h?$dLZhe=_{ajpN-^1xZO&b?&vx1w5b7jC;fPFm{c!M>=xBBuFZLg*Q3Jxme7=mXE}i(%KM9t|cG2x_U1A$iZz%mW&~Gb=KS z*KYqAUox*+IUVolRGi6H9NFDscg32zjaszFnT|CGa9lh7{;!z@OhOy zLZMsv#JHY=8e|A^3$sQWK>T~s(SzKZ`kc+eVQyb2vif>xJO^S{*0L!8+42yD9n?De+!lQ{Tpi6()0N7 zLw^3rT>G<)g+$HP`R!j+eW|WgBUhK$Vi$~r@WrId`)lg1qxG75u|nK*eXz5YNaibK zS_$MPp;jez9EHf>;t6yfD1%H*KI7N$MnCE8*)z5Ou3L#i!}UC3ydQg!o71iPOLoC* zzSJwiCplt8*ZTRNWYt2W`H1eoVvog#o5D`;&J`*G0w1@djfV{>w?atR#6hFlhyBci za5LP!gTRN3KZ%&5o7dLX_+nh7>+ahwwAfPeyS=FkC%@k+y~SdyGjc`6UzVPIJ$bKy zHT<&i%Tqc|t9{kTYwBT2yTJ-@!L_)a7L0oyI^h7s@eC^Jzw_D&Qqx9i^cFVlyp}3= zhOk5_-Nz6AEd(+^lMcZuMg*NlCR%7Mlvp{C3AsDo%MCnvVDDOMMn`DQ%u7S?dq#B3 z{je^#yW|hYuTLG4T-Gy`-5OjHi*2&_vkcTTGcy4N;J89CxU5<6z(`9=OF>ELNAR~3xki-D$y)h3#6rr6Mge<4E*)0~-eRC*%O|1y`L%f!<=FD?8y(7j33X1e zR9mF&%Ow^$jvrceNS4os-mJ9$zo`s8fk8*;QLfu(V2!3K!^exlrS->f2NFgZb<=?# z%woZxTChe;qLQWGc#@~L5fO3=zrN;(OMCe1o5`b**I{jQc-Z(9dMK~gVB?&nwS!&H zuHESuth@g7xSp}>oVy7Yey{GWUP;Y)yfX!p~SeKHA60Y0EobTxpAvjEIRrj}G_h`wneHu@7_9c!grOh15{Firf zIP{4gc6?VYgiXQ3%uhD|vRJ#yR3RqJ={>yCctNKYVmJ52ire3^Bc^+g>GQ(99NMYo zYp)YGO+5~}L3d262o2dfcj4iyI)?G%V=>YM`~peDB}=oe&<`PDFB=1w%UU679%m;B z`}`MsUu*_6ulXF_e7_G13qZrbr&Vb1?yK|^6~w0%p^Zf?PTkS(h3UA=xckAY6AXQy zhXb@t`j@W!!OTi@(%?JzS=zMrA&EexP>0Ih)a58om#P39SY1WP^MX^`o@1aL z+@LVeUvq1D>#C!#noX7P)9&45-E)nxGSheOwhtV#-bJ$?Y)0eau~o0O*j#+ROXM^= zdwtapoA2H^by(fr@~SzMYUt6e)#albe`0Z#aIOAOXbqhl%*tD&8EyYuR}6=)j{pS~ zt&=g*4?Zi&By3)??hlW~bWH~0LtDQu+0;XG(R$uQH!JN-m3!&3OmOZme4!ib=P)~5 zM=9D&H(yapjWv6*EjWMexs$ZJ8r|(hHVOI_HrXegPwh?}^kQcvxS4%>URn`ortNw9 zcOG0&;xRk~`rlKyn`jP`j<~!e&zyy+mb^a`zJBI$Dx-^gJnO6*CfKU|)$?iq95_m@ zPgTI9xEL+GqP-ZO^%W z$yc-SQT<6AXqalv zcaGY#ihEKyl(*zC)kasETI9Hu&EuiKg~CDG=k25Ox@Qr%4Ng~k^SizW6mAHdd;Z5) z-LcIp*BcdkeDngmmdihFCX(xfU2P`k2`l#Q0PbUOsfw&lYU$P!0wpX3bNeF~=0gDH z`33d%66#fN4-hU1?%SO7Mr?QUodXgx=Br64iEksu@syS6oxT)97`P2 z7oIoyMp)-$)#ermJ{*rd_HRI#&tG7(o($g}%U#!F3zL^s!JKv5$agp8xY7)yw3Dn_ zNpf%VD)s*`NZcrV)gRHcB6KjmYpr|n9`r$#7~FHh6%~17|fx~Z9NRgrzeABZCtPja9YHHsET=KrROJ>~PmPh=({M}1~BV4o6 zZzECSC|d5LUcV@F{HOjRm^2`^?l5D1&T(UX%c^N3f`7Zp23+4De{%KO+G4Ee;gpl6 zW1cwudYw$J&!KhA0y1GexnJ*gm3Z@fCXlD%_Cq{)M$Qi*Kdyv zpyyLGCjo@bNl)#SbMwd9Ec8L|tqxHHCOtJwk8imT)|7fTA2;tSOZHl-rYSyRX?bft zwY|dcF7-IkHf$+p_uwwilR|pY>$$$cN{h8o&`6!VZlAjlH&z?*k^O^}ee+gX5jck$ z2wHyCu9M3PUmn^F%JM41wDVXc&Cgoeg{k2ur)+*cK0cY#PDlIx^R6E#6D?3|->6l3 z@gPzQl*F4yt*%T@=7cXkxV(RNgf|)g!W^MCSG(}dAbvU10Giq-Qv9q9yi(ap9?8vPY{bjC_hdjsd_PUAz_UvC@(lZ$EKRB{ApTokRtAjEg`U)H%BxQA( zIcO3i)uogZ`n$w#)Oj6R``~z@zW0N`;)D4aL$4{b2$?9De?#PER{T-Az4?ceA^Pj* z9v3tPc%ZS2@!!+e+%#o7`@2)`A~05xN`wR!y<@m9#adp&+7slj?=MnZ!<F&WG-zoLEm2A|A@~nLm0FD#dUBcYyA2U)--Abb zeHut_z6^udL))j|b5)Q(-!OX+-sig0OEFD2Rl#V>@lnlHh#?SVe%j&8}w zsI7j{CyJ*G+sP}aVbb!tJW|#jEx;Zah9_VC`OwsUg-D|D)%F~pmF&a65*6^MFD@l+DBro@^;q> zJ*+pmxz(#-Ex+=bZ6&B8Eu>1U)x0_;;wm3W?Iahh%2v`=ABCScSHivItF{UPMbAWP z-NL!)1^FqDGiR{}qw=XJAvz;jP*SZcmYYqW=jT3dUJp97Ufy{PQD05)jmKVv@2b*E z`04Y7j@`kA>kW2Ju5a{h^-CA4*UPG<0hwm6i+0tf*{fmQ(XuRCZpR-Fx8yzd#>O_*Hb@?6MpD+z?iXFJ zXXqaVi|PLzK-b@Edagf{psv3hYWjNw|KYui$*4oTCz?@*;g(|i9nqT6ckc-iZ_8C! zN%rzCeB-p#Q1cqzXsTJMrwyGhBS>8Lg|vyB31|~TRu42aW}oZ~s`Qu%&z!CpzhCzu zN9=?iMYNG}z>3)wF@^7W5CLMRW=;uCz{5cT_U#OHtr>_cwyxmYDKds^Cx+L zc>D;4*UwvDEn7F0 zkJbtQkw&1h4ivv{#+?5)3V--VvsC_b=UZ$*5*7>XA|Ahc{K0aHMO}DT2{B5pPuUYO{Y?z&18d!h3^MgLwPMYs%Jj?$> zvL|T2XehOr@!e8Y2A~NMi#8@dw>pQ$PB*=ewbmZS-IoYOTIwr{boY%Q$Y6`7!&9gr6iRSa4 zt|O+Ybb-wMA%_jz<&b|@0VbSpS3n08=?Xo~lMX>!c_Ckh1=LN!!NY_tZA&OHfjWzp1d`+s{sK4)|cj&>}rkpPJ!g{o%vP zOkC)>j+xRS9nLYhnehSjn~GwWGcqzVj5Txf5-b;hum&i*zvvV0zu37{C+qIhEy$YR z?p&0|+F-4F`c!!Ry9z=`&<76ejW!EJAqjE``XH)GK|!VCfEljEe*U&+hV*5BNxpGd zdyXBCuY}gW4T7`8zZ!)J<7wkfk=D?-^Br1v2pn2Y5Z$S87DKmMYjAmx6Y4iVzr@X% z;6a6Zm)b?)q;}(qm9oxC`x^-0*BX=E)^p;_gx0gu&1zV!Nm&4}#`Za{>}C15p1lBY zj6ruBravgZjS=tV;XdZD644YcK{HWp+67N;FrF>i`Mwfx+4F+!k4{aU(Xk^PBxaKW z5NuS#-GD`i7^6qUFo>q7r$>j&uktf~zJo+keIKqCY27?|0moRy%5bWJ@@r3m2C?_20rLL zGycOd{2orgg98%3a{6Q;gfyEZ=uvxC%HKSaJp15<*HvlX_Y0#|8{M5BFy(1l^{~3t z6w#)2L*<=y!}9?Da~eZs>)}tV5Vj!D{ov!tbaYT;REqXQ-Xoe{HHujFMt2pse8L&u zMfX`=jWOq2o>K{9_)6iE+Eco37ISoCO=gb7z|SAMQ^d|o9v>xcl1UxEmP4FOp8{fZ zU<-@}g@+zzq&xK#$@jGJW+*CzFXqOi=e8xHhu7tWs8^3ami?y}K)~HIXShS*tbC#% z^HZVS$Rd;JdHpqg2|uHa2+U{8Y-1bTGk`#FdoMNy>_9k>38Fk7Rnc^N)nsU-p*;MT zBgA77T0+$LopZ56zVPSE-&QNcEUFmCsnfq2B*PFne@mvc2?@{I{3_O{YwcAFAGl{Q zfAn&El-GA&TM>@lR3D!#jmV$jLL7*!jIcj;HJSP;CKENh)zvhmY~EDIeeUkKV$cpH(L=3ke7(_piie$JVJIo9d zmL(fcqViL+sr^1BIJg~O);WK55>w;u@YSN1-F$DLcr>cWGomsP%nblsP9xDhXif?m zKG}WFEePCY1;18jOU1I=po}1=qk}y^D&;GvZtBnRc8E z*fXQHXw#`*obGszq$ywI`PC51)*%x#hC4CY#71p{M=2-e@Y76YONe)n-oBu5WiYb< zw=TOV)&9eq`}j~BTnPSI$|=VZbeXEX5SYR96`Wp7bb=5VEQH>Gg%G~KRPc*p+!{#s^TQS5|d*0exBYE@{FfR}??RGoI9qrAbu z+q*T+ZzjZVL$jn+hycS-3ywmFGK(ZhQmNEKhD;Em_lt(!IbnpvDBB@UYF-}m#Ku~H zk;WQD>5Ba|!cul5dTHHS%EWpZ>1A>jj_L={t7(O=08z4mijo(o$a{NwSJi-$w zRFIYCP(ZmKyepQCjj5g55wvi&jIggwUfuZV84@~uzWWS%C;nKxcb~KMix~)hi_y~% zhhdj11+AHHsi04Z)KlCKGFSIU3bZMvE3$DaE%Q8M%)Y;x&3Tt4_u{BNQS*sqItp@} zYzdK7&4?s(h%5si=?qS?XBVG;WgBy7n<60+jgzr&l$f+J18)`FE2#Ly++;XKTtdRP z&v|D8bkV*tck2Dx?Y`4e3$aNgMb780ciMYGo>MqR-mm7=Yfx|12^uC;=Oq|W3A;QQyevwy%W z;b>_U&SVe5C(d|vV<=dVpN~nU2;0?yn~=Y5Iw(JH*xKGM$k_#5l13b~so8=tVa^tz zvl1^N?%X7(w!reH!V5PPlE>ElPgpPsX1a%uuD=YvL`o=_iM_9$E|)9S8iU`dMSTmm z(@OO-P>`nGxyAzeOJsD4M$Q1lNSj#zW*hm;XZ3Mivnb-IdwhzV6(UIKAwI=fPqJ`B zuujN$SyooodK2jWMgRiqcSnw54UnD?*S|hOMqd;FAuU^+&B4#g)M?#ZgJ7^pfp=E& z5G}IAWZOF->$* z@Q-LK9U_U(&nnytrjR+EI0p71Qc^?E$D=YRy+dLYC>Ay;E4Zo$hummWbfZELA?drF zjZ{L~qAZ|N@`N8CeJAlb*`m6WMv5NWDwl|~09v2V9hRZSJqhY5x#rAbtNhiIp66S< zuIWEo!|9VhnPowVB@Fy*F`!Q&apuq<_k5UC&cp=|^2wNTA@uPXzzn_DdMRj9!)rMn z;X{7)j|*sfmhoP**2z+-elpoZAU9F`Mvq89aKU0$@DRwI11&9f$F0VJ%IzfBfodwc*)SjP&@c*~3&vj&rh|F^0p-;lLJN=-YiZV5DX#M@jJd zDU8?GBg-$Xs=vRx{TPXcvc%a&89Q~Z2XQYlpE|)oo>3}r_;RRCYSIMxIhq+U{@DoA z$_q$)b!pu)b~^MN1~2lmkqXmE-`B4Qk2C+Nwn}lg5){2iQ8-yokcl(LNKKml`+Jc2 z7)e_u2frs0rG4?6>Oo1CHAm*ZGf8Uvl4Co`6AJ!0yXN%Z4DZgOtvTH_;(z~YR(juc zeR4#Zhx`b+YzP6F29}RbkfaP1Xx-ccQ^^rKU3&EaH)`2N{Ox|Vsx^zxN)_*)z2Ecm zRhJRYti_?JJ0P9v^yJ&_c~ZB$>`4+YU~A@Lc>hXLhR5TD(#-b82xO$-i}uCzYpr#lRbhfG|Ydvj}wd{_<{ zCebS71EA30*a9}8Klf3N`q(pj!dZED<=mc=SA8>lZ!uK(fb3H%MEET1vtiN`5wyk6Oc! zJxwl~ECj23)_QHLzw{w@R`(Inb#uxqAl))4;QgMsR6@guqoq+Vt306Y>9D~;t}QXR zbo3But%`}D>+o}6EVWn|Fz2JO)2u5hm580qd3P`XzDWY$h3!{22sEjIYp^5(r&=)o z`40+$^gunscB*D#VxqnY!#d6HFIob1 zPMK9DoR3%2Bg89N-Zz70mde^H5)u-_qeSx`L8BLWjW)fAEvhH~CskJRvncLoB*tyT zH@b)MN4w%5zYe|q`9PYP#WT*t?8k>J(Y-z8ZGLbAO_Q+jZIACe5E6TfhyoME(I3JD z6xuQ4R}b(qQS`8U^Z}uVw@5X5rd%>L$?F0H3^etV3F8(lwrjy`@q?$wNRv^Mhi)h^ zL!zTq@CUNPdMXR)u?UFJ0JG5(TpvTm*2s#AJ-S`n)k}D31DdqSAF43+fE%Vt*I!7; z)I>Rg+kO;Pnk)K)?FK$UOv)t$Vq3}J29H>eBR7jliNVz>l?sCeWYCMqB=q{>cSG%+ zdfy#`yB3n@WgLvbvPxN%pOf?L5w}NJKIViz%nHsIx z>>a!Zrh7lDkQP>^rMGu=_x1EM99UpLK-vUJvEt_XaH2Ws+4j%km1DZcR{~o(^Y@@} zxTJx0Ge}(mhS4UY+A_n=(@N$&u*e`XG4LhPzEaxQd}#))Ul|U}W;I^T4*d+3*c9Cn zzq7Tb+148v2l&!w0qm^Q{|Ley3+&ha@aEf>;{{EtH#m}r+J*b#QB5HKPTjK_%YO1U zfdq@=xGwTD(AU(_IoQ^Vn$eU&h&3P@sVnS|b6{`;itclPsl*gR#gR_!9;qos({z6^ z{U&D7)%&$xu79@qT+#nxiC#F!OtnSae!1nrmX^DEWwT^bV$ri^zu5?t&gzvtKiU}} zs9Qb(7#J{$rffnmrzI6yvkdFUuD7~&BfZ?dO{(+QKr{WOlw#2NrRd^8pzH*zr46`W z>UBBu`}Au26&3A~lr3z88IMNJ<`K*q0{>e+8bGzmv#X(OWh(&)Tn6m|H#amB)Kwv6 z!w6+A+V4ABOq#u1!t*+5o|cbUUIDxVso;T=p|{z6l_cDwNOLs=Ap6^|4&vN?B~%^9FE9Uk@Q9RzseBpEW+_Wc-~y;} z%V!fG)Re}5f+kB5 zBbwGML7fFgkS}@zL^&EAeKDjuz>*da7)by5G`Vu{@&^_4E0L6b)GG(mvg|AHpGY>J z)xWjA70w-_f5?#`qY_IWhmn~YZ0K2f!8xL`foizGBy2!A2b<(+cJ9xaC{Qm9{xQ&} zS`$ShV-bZ6V3E@7-H=7H`)rT7ZLU%L$Fr#GZ3Qf*1zh{mT+nWg5nSRKr=9(gNf!$t zQRW^h34r_h`YOt;>GJU1=w>=_mSH5rG(cwikMXI_a1ml`SUs`c{Wn5RL_-K}tMgxS z?NzRM<{N2mX?Q(nW-H1k3JLyKkVFo&ms?{3{5r)2wZFk12k~0? zAm2rr2`QrijGsbwqEIqbsh)Sn#XzYxjXRg;kYyuyb*w(rA1-#T`^gRB5e^uJXZaTc zc43F7?zh1QTfrktOt)|(Y1 zYae=U;%9P@N3;Adz8_-J!8k-K26)A9);J6w8?WdSNtD~by*tFz9=pG?E5yvo41Y)E zc8&wjth^O6z{hi#$YWrvD6MiAQfos=fH|qZ*@?h&nG~&A`Ho&;4BRq~2J(%!d`Pe< zT{<81J6{aM!ilCU7;s`)wKbF(K@;}8T$m#H@oTFv;@U(>MXezudHEm`t7se7hGuL8 zt$njceWywUg4)C(-xNlu<~H|Be|`d%{^FvE6plY!AQ|QdkM3RW>r+_W*-0+bZ~v0> z3xONWc@x=j0~0qFv4`vr>j^&b87=@ePUTl`qgX@fW!8WdS0Jwp4 z$0K)lmQ5F4mrKsi&)*E1JSvYa)!S|gwGDpg?Qzd(17-;}(Sf%$c6fg;1U$14eVQ<_ zF5+jRu$nKL4hDY#04lFHt==29?qO*7$OBQD9A>8ZMSM5_zLq;Wc4-;%o~SmXHMH73 z;KNiY*+69h2t1sVL-VWfFSHAE;@TpN6~ZjnfOC{zlv@ zk#*KCelIrcJX2yMGd#L<`VnYEMIUePfyE}6TStj^A8%Kwfd!-+J|e)a4iQcSoNca6 zeq6J`hGyUKNu*|U{aH^vyBXK)6{4~kY-QmOS73yx6lkeCKG3YR$_Kq;bnz+ddJ`IG zdyUBXk-}C3!k+)l!37ChlGe&Skb088%tUAkxkJPGkImRAsYJxmBF(jn!(<)rPY||) z`{4FE2NmNyCActJ-EgvgD*2Yr3$zhxn1x!E z(`+_%LG5-8yMO>CM3=GZ>8+9y!A(}DjGaw??2uhsg&Uuz07&{J#qkIt3HUtBLP40O z0JD_@B&RLX)m01$EgRDzK5CG*+Rb2tG3Hv%P}Hx`EO$EiO&(KF7>PXS8bFuXMKV1m*K8Ez7XCg!FJ_Kkc^d4lrkBJLnh1$zmp%7vW(3JbK^@aZ{J|Crra)Vk0 z$1DeC>@p=r^kv>LUL2s_(HkI~l-(WNo8f5g03nJ;g=nGdhKA{6ms6)sK@na*B>u$! z*MP5v{Pug~(9f#>XNV~PhH;AcNT2sd@zr~6211o_|_ zOMR|;c6RPhMGK@qSzSGJL9efo)mwhH_XLKsf1XW4cJt-x(7j58{ri_fdvg$qhMSbq zWGDSAWdk7`vlNMmVkjB#f|~K}8w>4jov2`i+D1vl{FjIm>6OaSU~=DYEwCrx?AuG! z=A4>xJ1*NRIQIU8FRyrWb%(}Qy!yX_WC7NEXM4lb-^B<|1Y%II%ep_ms8-bBPT*mC zeZP{z`HDjL@=u}M8_Sp6ds(m>li}hGC*c7fEB_&zj`@_%CjpeXUH#sMpAIZ|4|6 z<=ml+RwnZKOsM%uf3@rw^WSA_dm_Do5}7hw5(1sjI){=Kc8y3G!GtZEjgd9~6~p$w zE(+aS@uHzX)H`Xo660diJFLDKSlKjM2Hm_)91&}vxt{FdI_=R3chZ{n%e6GjlqoEX zzSi}2E4Uymfmphlt^qXdt(47L(2OVheAX3Q(3N%Y(XEZsmqT;l}*WhG(?(y7K8*A%BdA;DITQg5DK8V)w_+B?4Kl4Sy zo!csFSAY1pcB0pE-Ggsfs_{Z%x+|Yq;rNZdZG< z^wgCUbG6%=Lx>Ca6&t{?zxi?v88#)R6S3Jle`}RzYqKLWb4k#8zQTa*%}o4EvcqSg zVlb3%@>Bj@K$y;blH`q7cGfI``rPV+{u@bGZ!nLhy@-Yyf-5-gUbq1l}uSX{)v>8DA@5F*AK z`!hToh*$#mLVxnYo9?;Hx#EI?0_)~m0dBUj7>U#Uf6&~k=_(~8++%{j`h4P`lE)f*EuB+9r6)1|C)tJ2J8E$v!C{jEQ5)qaS<4d`Ab zm^aS5T{h^VgN=ixfe3s0*P(*-T8`e{-bXrptpvSy9*OJ@XZ_?p5Y}Vnz7o4xebVk?wrRR?Olu@}LFdyY1S@Fv~-7#J!fCxKMJV!iI*y zt0BXJ^ZnX|$FAA8-@{J$H?XyTF>p6<>Wl3ZGMG8rpnR@!N@3LRO|s2WA@|YNK(5yF zGH7-ClN7hihKFC0>ci|*$+a%NoGc{r@avM;Y^Cc{Zm(0|=AHTIdfH&JA=yP@uyAqV zdWrkS+9vT2*Jo?e#GMPqz6zC04c(i+Wp$C{=sMD2{mHGG*WRJtxQgj*BQMYJd|i+7 z8M^i#U4%t4p=6@3zQim(OwyPetM|4w_H7J&n`V9_jOZNSuUj1|rI7bibW#3WXklc( zg&u{C^fw?9UMe_67SVm_4`zE|pi>?6?$g&1Yv~z@MWH=~ZfcF+DHVLhNOn34ucIQe z4g;A=q&Uyo$+%X*gV0*L9cwvpa--?=CpL|ToQ2b$jplmdIx&@HqH75JqK=*Vxo5Oz zs{&<2M`xz=TZb09r3ywpN1!O+K3)ZF*S9zJP7agB2YN!Ra?Ebuh9nrHmMOi+vZ!$- zw=*j^>E33fNvw~yrsOOC8%$)Nc8poCjI8we+l&oM^{)f;b#j)X=Q5;`Et$C7r?n2_ zP{LhIBzF+ge|K8-krBiXzGye=lO&$hb0#x2t2f_u^FC(hh=Q^^qK>zu$9`2rEF7L4 zK!o%VtCkg$ov2X;PUK$CaV5$SW~!`*vwPes zbh{jn6e3LVQg4V>L!qVa@DnjZikQ}%!+)ORVGSRjBPPvqG+nc){K2srC3p=@rTPdi z>d`Dy7+kdKYa}n-jF9oxfJNUK3B3Dp{;QRDO;yi)^f(PZzR3#zq?Ql2{*TxgA7xx4j9XE~8H+JPP^w`Hfno zt|7fF=l9+aG5bB9tJu2eOT0&b>#y0>R= zWL`Dxes1OQJZVs^q(j_CqbDZZ2#O7zV=+@#nK>HjJyBN%O#NlL@K2gs14@x{g8%w(0yL5b@4rI`>-Z;ag{=|J>8|Fv*{lQi5zJH&1(j zC0|#{!|v@GQu6y$Y@n zxE+$@Q}8QUVKYZkvq`Zer7QyG8w5g9`Emr3)`+4vdnP<*rF=OH=30Cf*p_lWN_pSQ zQ^Q?jarS9RMj05Fjhh0*|=krkn^j3fJ4SZp6ew2IY z&uy-W4nRS6@-B|@mMM*&QV+fFWKah3h1`~I$I^auyV2$*_7G;}n{36*3!=#BSA zVGO=SR?iZMOl1YrZvx|5mONt}Qw*NCG1>L%59m(L967Ty&u;K(5>q_6k?7v1^0V!o z(d%R>=b5lVw&A&M*9P2EsP=db&0+ueYrEI0%UB-VM$nCiE<9tz^e2Jhdrf+r@L|=e ze2K)Fd|P7I)5ZCcFE2MSTMU=D0sSDPr>ZyO>0P$ZRt3L{(bRXnCq2_dkiIHLT*`*( zm|;~W9V1_BW@^@Cr(ALTF>77bx*&~;1FEGmI@=iu3nOzSq{UIAjkyH4BU>paeiVUS ztE9W0`6PL%dr$0g?32$os|guqk(HMIk%ArWsC_T$a#FerFk0t=BrQpq^OV*sz0N*J zLbj)Kqrz+}0O{r&jubcedXD`F$U71`nM(|P7eu0n^eX&`NQ|!b^e}ub__@>f@`r_26~!~Iwv{(4Q6UU5 zc>bc{P1F5t6LpIm91ZQ#q~nd?Q90t!Ny^%inmO> z`dY}uuFReSa{Kj;yK^LCtGgReY`b@>={No>s!!r?!?5fse2h4Q;P0u#n-aoT(j|A; zb$s@f7?-EW6yJy{CGB{iE+cl(z~0nhdM?wwb73K)C5DAVrcVOH0H~_<#R~v(fy!}5 z<7kXj1mt7LUnraVCq3DzmSI$0@*a|+kv97Rfza6y%qFq!s)`aH&*x}UXLlRMVpR6& z1c9sso`Z!HhNfj(f3UOgJ}QzoodX{T{kG&@?-xna;q<~fb$MsUN9tV{Z!<(kccqzj zB;djwm884J0^ChPvB%>`A??lt(PB>`R@-4>iYUbrT@_xRxI;aw|6XU%mlDhP>R>$+ z45jT0)f8y7N_JfGMtUAw;P#4N{Af>xlkFV}P9cApc)zAux{mB!zdqeE z>(?!-v>rUeN~q@{QSBPaPGY7N`ys;tL=TFGKHfJx__ZuihN2x6R^Wf z_H84NM^YE(?{4cK@XWgLV&9jN3ay<^Cq9IIy4v|utZJhWi!QIyU6JIn5k=SOS`!7x z2+4$p9f`Uvnx}qcE9YIwTO&J}AEj3jqvXpohEO#R?j==drXaM#Pd0MM zC$*Z{CO`x;E7!gxg@P0-Ohg6e5R~RxlFt-r6C8^*~mCwSF-%HUoeK3Db>P$jP zUk6sHiJ!%7SWUwX&(m5`USs&db@1IG1;^L@zi=cL)lJtpIl$ib1|Eq$`k>q}IokjE zo^6GK^V2qqTXtss3E7DI+4^qnQ>C1Zr?|$Fj*V?wO!WOYBZUen)EMJe%z4Kcamh~w z$0iN3kH%;$*ij5A5U*vg&$_O@jBN^qqYA}c3I+KY>$}Kz2i*BZ)iSN5wQE|FzSu66 zC$qpbcg7q;B^9f+BC1M+>d(&>-e}+i4=Vgl`Oh@Nh`}O0!=$+)VFDA2E3tc|*OCx2 z(TQQ!N@P5(KUQr0>7*zFbJJk`#Ax#5H}{0XeQ!69gc^j6FBQx8 z`zkux&5PE*dxIn_9TG7M8=I}Lxuqphew6z`eNMCf`XP8|`c~1lw>2{{HF3>78=AME z-*#hl6!DW}2kNjl)35XWC)eo7Fd;)YE=ZDkI23+xL$Uuq#3q!%(lLep1>3`wlu=Rb z@8tLoka1TmFrHU{M|NAGM=>W`ZmBJyK#7x=U)uny78Bp@4&62U28I*v1yd81Mc1~| z{e6S-V;h`>kP&%`nl#WBfFk3i-c~}xWp%#G6sPw0W&92(!`d8DaYyZa4kl;@;ie`m zJN>d&Q028cR87JH8qs(;5GJ=mptLRV(i5vFd0M*os6w4cB)&2d+!UmuzJhzbLMoYQz{iC4&eG@axCP$cqW^o-!`~xNu;uyBBXB{V0^KTDREkGYoLtp8>tYTTwjya-j?lLtgb{y% zE=W3GG~H+v#$@8v&u1^quTZTJ_602;CBm=DgeNfG`D9RZ3#^Azg5JL&&3_x`LVU|h z*a_n7V~>QTI=sL!!lG*_d;E7qJRQIYn57fWLhVd3jIQ;?fX9v5lz7au7b|Q*BtmI4 zI^}Z1&d2x;1#h@(^G(sM;PBa{2>35s@QpB_?!b^0Oi=z*@jC}g{?6Yo17^h4sTs$* z|2Y`%6TXR;;3*)dC=F_+3#j)^MP)@QBblGLQX~KoFjo zY3+e^yn&l_K&BE~8rH5LTOVv8jx85i`7wd>370@^7lvAuZaGN1uH`!XcMFss`%_?n zMZRxQQ{p>XU>uo*A-?I76hR#*>UXJnUjTaoI%n1w6nqZ6dzp4G7eJs-2C3Xb{G&P_ zMYY5<1b#eXE`?Z;1chosL6va!fp3N@h*Aq$b~55RLXt&F!hB6ro6=Y3F13jMPDyP7 z2y}g?^8Ew1%;p?3`mZLrOu95^wYBax%|zayiXJMU<^*?_C`?scqczRMk-b2@SJf%A zNaz5b%E6Lh|C;BAKh*}%*0#f2#;^N*fkdBKfe~`$iQ9Y=nP{1hk}7GNge~w?Tn7{< zy)LtMaF)iYtfrd;cQG(-gWv&7LN&Fuo43w7`RHC(pktzW5g!TyXLIb1JL*VtAh)8c zyp6~5qjei4BwXGo>mw+bESWd_7AENULwEehku9_Kw?(6TUeN6IzM>=$xjB?G8u5Yy z*>~GWT-XFpa^l`e385svrF!6BAy#yU0k;QlP(~uKZtbfKhu+jWsRx=S zXJ~kP9-;oPn&`K~(?@KNm~puorIT?hye(giExc9#m=bzsBTXFY3mG^n=|D>ksvd** z#U;f~&sXMem^?|VTm67!Y3%7ShyP~RTN0FB-Ze<{m)}zihvhp43mc(`>M822U4lYg z4X^vGldkNsOd4SUSt^Agz2blEKq@RNj1=25aS9jwZH0%g(T9Ct$?d4ac9rwdCfr=k zu~hx1qcl^m!e4|SMD=jx6BLA2B8%PA7>xO)M6+99(;d)?(yRTCVX%|zFqkFr{HH!` zHfl4%@cRPv9LuhX8N)xPsq-D17#e7)%ejzE`xaD5q5!L@#DzgpsJQ!(PoCYUGk_=l zx7WT*nHp!rcX*9_`0{8UpaxLc@@yLmJUp^*No95VLf$_Xqx8oA zj<+8n_R=(y5gg_Og7M(Obfaay(}oEU!-m6aju}oU#@a={#8*J+UUK(fzL2_mc@Gpq zgoDC5hRYct6rlx0VfW`xO_2h)eVtV4653*Uz?yR~p4h!fWEp=k0G%`J_g6i9i_U^A z?{^H^8I{Dg?MLCE&c93pJ6jR=iPAk?p10^B2r;8s9tBb%!zi0wPhxYC;h6REQlBnZ z0+%iijPHhcD+DvR2_`DfSB$;*Sf@|sh&Ve~+X`Gh)AAZA8*>CYb~{c{+S!RHB&_eW z#f%-wY*c$HvqItIfHBx$X$n9c%LJA*Ul$&X9bH1K|K+oe00ci_>FB@ts!<3`>W&|~ z7XN-l4$%gyv{7(F>rqriE;qbVYc}KNsWw^~F`R>AqG78_%@EywoHRT=rb!er**6_k zHnoYZd}S1;gZlBFsky*WJ|bPT%IXVmaoF2LA5ylY3l-skFxil>^{|;v!>FcFiZ@Xl z$5HiIcjcDBzF~c&DRl2*w6(Qmz?D6?vvgnr$v%bZ)F{21SHM&bW#lWn$Jf;SB?(Bzs`OF7P_DO-=Ig@Sc$7dV%tg7ROA!tl1v_iI2~{9Tk`393~a&&|FZ z=YNGP^53~x%i&zK6mzN}I$l!lp|~}Qs5$exeE$l+wHjErHuMrgG)AfbPm*!C5b-rR zE8?lmFek$vH8i^6Ib;Q!^R{oN7FhU8%{~z9ke}@+?>Wl;&j9l&4GrffOa-tI31!zgMA8G-B_)I{E zPjTcq)lSzoWykhCxI;;9{*xL+WDTeWgzu|D%P;yw7qZXAqRHM#CUe-SSc7KY6cvF4 z@46iVH?p5Hw7I<(r$o4NV5uwpSN9c_fzKQNd`C=U5`|J^XZ#gB%}74>K*0`^nrS43 zI{1mc+scPC)BeK6mKRs!055i_O`;TaH}kW7pHC)E4GjQ{{<#8!IgMk-4?+}}DkF6Y z8nF0xNLKTe2=A&Q?$GFx;zuNc5r%(|2r|-Lh)N#f3Fb>yr4eT8BeV1i5&xDTTFtN? zi}BqnOm@vNSm40Bc8X(1=u8lpueNV#9l#iJi>=SLivmGf4ld;#9aX}+p2bz;mOATH zyh~&UtlGuad=M_kCMpl#0(Vzph|xHwxgy(Mg&5vzBXsT>h6v@;H?_6qqEx)Iz3X~E4V<(1Ks|0EXAvx=KA_EO&%7Z}7 zP1uaHkec6z-)npg8E81GXt*1s>BNyLWM_*`(CPY09bn7P9-k?wN=u-n3Ya_;6#KUf zL(GT2!4QMTd^gUAa`fldS15-)mR%m-X|M@9)LurR5`;1x06HL$y;ZXPK*f1vCa89? z0K1rpP~HkpBY6;i*Jr02nYW>AWO*T|X)vYpqBhgVesTc}7g!4<8 zVY9<1(s4!+CBS}3d$`fTh2<^$)_?%J@Tk}gcz6@y-VpzwL;sfH|KnDQF9e4I8wiMj zig`%&ul42{VV&c3Xjlq6xd)NMz`pOOmT*=G;^s}a30gXQZY$yoI}oDQsNrx}#s2#! z3PSmi?)@)Xv_;R~0F4iM+jry&TG?5UNS?tVM*ru}&uwfoTwXsCb;ai(;U~lO(vbzr zrqg@h&hn#4>gCxLcIkX3(8EfJ2eBcI?t&0blSfz9Z%XVH1%duoI!zzIYa4s5(5)Z; zFnsbTkdghC$Y+zZftqa4!2nfX?|btR?4tbjQn~3_RpfPA)4B^5<`$}T5-n^mintc1 zhJ?5KgrHSZbAA^C4u?*P!zpt7^!jYe%zWj zj+0i3@ZB(7NY@*bditFNwgXV&gY%B>wD$WdG_{Pcb38+N z+|jMC;S?)!TX&CrL=_Jz0nY}{(S5gN1Io#QkS~H$tPdeTFkWZLw@fSw<;!6MyQ`C0 z&<33GNk4RBJ|_Q@1(I7Av`7YU(>y6dm=YnUy-0RKhR(4tvf>Xz%mB%c)TRvMk* zt6>8BE9BRX$ohGy4x&5Ag72px9-j;_V(^>Wb2Lyg-JK|?KlVz6<$uq!z(ZAS7Gn8WFWmvtIN-j$Gp9ryX&Q95K zYOe^Ek-t_VY*2HLLR<8v^FO;+WCBV(JL8)IY4nrN1K!)A9=r=V1S3R>_1U*>w`}mB zrUd;H@EXGX-`=1;03}iMSU+20dMoB$^j!g3`b+J8tn%Mo0Ov2l@>K-%n_>N?kq9c) zfl0~C#Ai0FtX>_0l7qeM)*eK%NG|+zO%n%8LzX!lmZVZNdaA>00A=EnhoW>x-w}$_ zpni`!p9p}Y2&0jG^U{i;TG0JNYjSkKFhm-H)g<~1@i0kFex=b1{6Q4lN(+^uz`&WJ z7WrDU-4^Daw2hxJXdhH|z1xmX1b^7e$~!v+bs=3%6LA{8oj(E8*lcoH4N)Vp==|MW ze4s8ITL1T?_Oh_N27jp=5%CuB>8C6GE3G*=>Cn`(LQur@z~kHAkOCP~6ht{mR6)l@ z!~IK#4E_$5rU`GU<47n5x`W<24kztQN|#P~{#{-WsK2qD`33}P%H9GcMlAO3Wk1U0 zIm?8_%K|mVr7h6GZu}q=iBqKnPSKj)y)e#8+h6`D95}@U88bu#C1kQ^USa{YGJvtb z-vR5@6)8=rJ6>k~QP_)PZXrT?R5k)L`-l0i*dsql{Id6GYzx5%!m=nB&Xn157>vlK|D8-g~ ziG;og8kjO8j8w+_ybU= zH^9znsl26#$6t|WrRqGx$#oQ3@0s8+sJTibFnl$9K=%iH_fV1L1@lB%qr$-n&)Ai5 z_4hqcLwsC$60s~Lq{wv4=DRte(}m@3Yh@L}+ANEfBK7Tvu^ga8kdsb&oEW8fi@G>` z3k3_O*THKQ>y^T2Uqe7MY+E~Ow?W~W($0CLnT&kQ@|5K-AU;%0y?ziLmS=)_l{o@g ze?fe6FRg_i0HVFC_CY0dJ_Sh5h$-Oh#G+3vU6S$NfHYwY`=br8{L0^h+bQVwzjLym z0@DWRNp%SgxQt6egcd9E0Sbsnjr;_J=oxQ!30&SdwcL#&W{WSsQ?tYYZTE23E z0{;^l$%fV#^kwf4a=`TaAD~oZ11>t)AAinsY32W|Fj5e>6qnLZxv!D^(ODa4ReY^> ze?`N}NH=(i<3=uWpG+c<{b;$r3f>xe)^WvH)MkJU#yaf|Q&y_yuXyb`jOKo!9|0#~ zIpHe;8e0jye;|XH z*w%FjgwnSt)`g_0UeYG2dll_BUv>-g#-{)iG)+M-g^cGW|*pHD6_q~ z6+|^U_*VL{3j&_ar6(?bl{gv0CFpQUFfD7*W zf8R44JI3cY1UN=N92{qA+DAy9{bS)cc28@}eCGMOLe01t{?~s-tY{5mEb9ojyO~;NMV7%vjMyb8TWoA6*_rT!q$+!HDL~TOmT*&jzT~U}^fsbTbkw}5X z6sso|Q!IH-d-35f0}uE7loMgR6A|QGDDN2i5%a-F$xf-=Ru&6srsdKFHvfqoGSpNJ z-(bO%gMIVODn(eGExqBv&V{?s|0mIP*yiQ(DiPXG{);;Ot9@Lk-X9tp4iC;J7yT3S zKA^OA|B>eYZNFL)(J8F z05esja(N5Qzug4m6%v$ih!vFQpwGJec!A@@&G2=&%$fjjx=C83i2V$Qu1_dUJ&M;ILpLY)h97>*O4_0<>WWv7-3=Uz;Bm^75%RggKS z0_)}+MG@g&TEV)hu8BC1BI}qh+Z;{0{G};+Y|Dgtgrvm5Kq-N5A!1_Rg5<=$g&vl3 zRAa7>Q{Si=XZsAtvvWJ6Nm!cvd`Ggui^lA-(cf`N)+5>4#5*o|Y)XXDOQ9=rc~5js zq4a_%7$;qT>teM&ydm9FDWCi2_gvrA&MO~G^`#Am#!?9Vx2{KARxR%T^R&2s>(i1a zvCPFEPL7wCaP}?wr>_7JFHM@LUkeNknF&)=eS% z6(48ZEto0uMY2bR#br#+7LpHH+pUD9Ltw{yD@*C$_b-2`ep{0ByKSB^(Mn=kBg3fdD9`acNfv(1+gdn;+~BT-d8d;q!-E` z6pOJa@ZFaFb^m=t&$l0yu!C2HSu_EWD{`a>uzi3 zEmH*&j95_(eNCMU6YqlROH#eAa;nQs)mVRhUwx*ylqGq-d16lJFT*-+RFGab9d#YYPq3a>khNBXE?o= zve$&YPjg+-2s^U(*`))%EUvb;W0E?wi(5@60Vdnxh7E+1YdNLm7!NIqC%iYiPJD54 zyg0UJM?{_%b9{vL2`jB}bxESx$NGaWb2^hg<98Skm$ zVs=W-`uW?sJ9QHa2&rYMWlM|6?QV-GjBflFbaa1tW060>M*X9aG6n#KEk8sZ6I3(L8L_TXj+N#&7cI0!7?ZOGC#)OBHCX17^jpf4#M-styr!WB;haXy zM;ftJOoHe=J6!&OfMVn+ylq!=z@E1klU0g4BqXK|58pD8v%pPz2841~DUbt|tO$p! zYGuOG>i(-yF0%|T2^4OnCuTtQulBQiPvHESyQF$EU7o2!OoNntv>jdmrcos&*R*OOWR1BbMuwK4Z0+4T_!XVrRJ@sXGWg4k3B=loDp5MiWX_Xa?AA2 z9}M396&&twlXLj;x}T&L=N;YH&Xl#e#KM9%!`^hMB1|QU_`|ECl3Ac!dXnQx=)2*3 zNk*(e!=v5W2lciskSxME^~uGl#b6t_YHxMUKH7*Ao)B*Pz5;`J1cK+vnw#600D`$S z>kuQK!b|#sdkU|G+esy$xN`E|TF#K7_T6=_%X{C5A}vspD#K@l$;_S3c=7}6jW zYjv`*--B|b>a$uTmm`eqEN@60aIw4J$Z1U8?!i=Zm3%$_ekYc@LJhV;v>E;psDD)U ze%c0yQ{XbZ3RPDbM(0J1vGk-x1DVd(4@%|;Q)6&F{Joyi%UjR;mA*%*(#fFF#fW^F z)d52X1|Al^t&*z`tIEH=>A?mi)4GjEoX;p5J#gk@;uU9zQ~2Dydc7$ty{_3w?$eQL zk6+MTyP5qG>#dz9pV8BAhCphcuTN0h9{cdJH~L%M+d4zB9dBC37aGRdj`L|BG1}5rqlh>yNq@#M%vH~)ZN+S`cmnWOowYxaz?VN z9d1u?i@F$RIGVMKS7zC5z(;Xsdq%~gIF?GhpFy0!Q>_YB;K|L?EP^9sQ?4pK0iL?6 zq#V^LkHXou!RIUmgc!9ftBb2;a031|lv{3=_jBd4VsGw&=K%RM-#=2$KJBu{)57e< z`^t&A=4v^TO0(#~gl4MSOtVU`b)_HO5rW`qJ#{K+hP9{zIzVotmpAmDVv9)LUbbiW zb!EQv4!aV?Ql-e<8n;;PMQ5l?kL8}~4~d9 zc?}jhA?%!mL7?Y92HwBcy4Q;Q(`lV{6m_1cjPLy-OIUmB!90+Ha5{SxJh7~192 z>(`=K)uYq7PK2@k9up1M;*zrLbC&9K4}Q*UIzk97l30N&c?mW^_!A7$DQoq*vu=V7xWByPnPw82g5T{RouQ*#hcR5>j^ zKP;^r9s)ZUw>JotTev_+xYIV4#Is?2)V`83#fLhYw3RJ-h8?0c3JaXc;MmTu!Pg!w zAfD+8^!AD+5O2ATwu-V&pZdu3j+@?ocFJjCo;_Sa^E2q_1Uss7zr0j-}xq$kJy}zo&N^4y>m0Wt2TYCM>wx zg{m%=*+hiOk+yqN{*!6Ly7yf?v*aMU(@_+#%wN*;)mTCTm)S%ZWc;pTD8e%^5+@!x z!coKPF!A66lH7iA4|VT9MNuWqhTC$F>taeHBGg^4o};vY{?d?X>qzpjlB0zB{i1Dj zqAKYuj~WRBR`%aIW`TRrYfALLW5j*0Kj&}wn_^PjmZhL{H~Vk zQE5hoFYTJ%7dkWH61kk`x`AhR(HKnDZMx7PmJDKss)Z`w(3JC4^S7L4L@RrF{yAQS z(cfnhg4Z!f*JrMNO6d<$@f+R6ViZtS#3-)+Q{Bv@H}zHO^dzT(m~QSNPGN0r8~Af2 zXZgrq-l89HffFF;L?b@3nWhzu*Yzq34i^>@+Ed}k`r?n?FI@AF>2OkaWj238?e^ul z6!}iWXkLNAyA}Bk9;zRhbUC0y7Fr~mT`lB2wsv<*OwN zfp1}&SLAAL-yJVTGsM1r;rtb{j zFY~n*4Hcd&o2u)X_t7i3@b1Zg`vZr;R$}!fS)KM3)tV{DCR%edj88=-(GApZYzyDA zy^RNx=_l8-W>&iL!qp?->i#C!9 znfqY1>*xf(v3M7IwiRDuqKSL&7ySUTzDGAk6p7RInR>*ZOm=P5iiT`Aj|pp|9xnqW>o(TVkaJ$)j{Wj=aAIp&;NCB5BHGw;slT|ECkYCfbhi5+M> zorD|YukwP;1r>Se!8dgJtEb1=N-xq@S?EjI!FV11=m$i3U-W=qyQ(_z%TA}Md_>EA zDV9wgBaa3rHfjp4NX3kxm|eCHUG?D0Ffm77e<}$(vS!#HIooc=+k~U1dS%3p4VA3y z-|k08A|AexWMq{-X(+v;W6@74z|qK-P0HJt>2~XT0of5-%|h!$r4?4rPD#6uM8wvTNa z&(CAL*|rspVb4Fjw^*1g7WxpMKXNagz98CNerD!rs(=Dfq8YF3QD)V&mcX9X&8=I) zPL{Cv5Og#Ov+dmfNC;TJeSvr3Z0Q#1;G~>=cmEVR_l=!tz9P}%IyBJi>iBWsLv8IQ zAvuwPU$cXi#AEic-uZ=3incc0z8d>0!@H+3DR+o7nMRLM=@DgpzUDrw@LTCcu62^x zIu?FrbKZRLapWN>Zst#0wVt2cOuVa1Yrg&Bo65XJ-fDyNQy<6Z0&wKgKMGg6^`eMJ z_ERv-RMz?^SQeX4-=S14#2mwo!x{h4z|7sWJy*23I=#n<70UWuv0u6 zBuJhib`Sg6!w!u6QB9VgtlZiWq-zpSWD@V7i5bA@faABh{vC?#tMT4z&8>tT8zSejf%_)Hgp1aqZGGP_ z+5?)ag21Kl%RrS=k1N}nov(mQj_~pezc?P{I(uU(CB9I0uGJei>{qRY?Nae}95LT3 zZ*c18Q%*gQ|Eb;*b;4^ubU`nbFE|7sHTxq{+M7|&r6o_s*<#{@!Sqn!SCQ;7cT-2Z z&e*1#8f0Q}WtHO>?hlwY*@eR!P1*-Un#UIx%RJ^Z9YTr-E)xcrxxFLic6Tfz&n{hO zvFvOPCBOt1QNI90Ii&t1mY;LD-SrT*h1np$vuawe@9;6YUwPFAFXYBQ7Y4|Ug=*i7 zm>x708=dwRBW4}N5_rkZ8CG{(=AX*Ae4Yx!re@|7^WL*}HzxAF)jv$dPPDB<;YGot zU(wM=!vpxCe0?Iz#Vd2F&$W_r@90h~%Fktfu+Q(@+x&pnx>{Fs``Y|yo+9Ghry2Ns z*0jL~p``XLX6B!RAxh+3s9y|f@A}r7e5LEbpS7nfMpcusr>9Rj60B}nC5qt+Ejw2t zNdMZ8D;Eq8ZJQ^u&&x|~Z8Q7)s!M(wPs0dKVQQe6OvD-6Rp)l>nj71ztzqk2a!1Xj z=Z}4CB2K3Lv!OKmjiP@;tOQ`KfMbp(45Jp1+Ou@14yTyS~&pl*?0Tc5xwl@ z!o#>+9d_{?tnd9be{Cu_fNVMy%Ik9O<*a^Ov2Gdj4mrEQM(#iPJzWN8O!m9(YR$$H zMtL>nTY?JM>a$mbdk-N;*tHLOee8VJd_xnsyG#bvs+NnnbS(-|Urx(UyEOBXXEkn; z$VoC0BLxQPTetBLMy5!v$FMyf3L))j_nH#cY8HqMoU+nn%0X32DuRSU0Rrp98?lA& z{JSDy!D-xWCKrY997n^=dhMOhJex7|gJP*Ba(`;C7Gms)XG^2?@_DDrm_JArjI%W> z7+5!-y_KD(kXaQ}kgaIG0ww`cg>--}EQC zG`_iXUsdq&)W;qaO75mh}7ny}! zbNLF3rWuw!;TL;^CWQTUO{k~=_^0C#Ij-ky_hbBi$Z40Y8>RSR6RuuZeSZ$(g-xNE~?=83Pz zu8J{LF3!vK+!A#q%cQnrl|#Fvc~m|xhR#l(nVkurkPZE3c0kKAvZ=zDv+WAzvcOsz zYAPB6?1{+k0EZ+a&*Sx;H;QJzYKdmrZS?L{cU)rbho1ajmCXvy-9nCbh5T!CiQ6}< zBdUA49FQaNG) zH1Ox)-q4meB%P2oJn8L>H6vtp|ms?HDlY`Xdn6M8%#euijt>@__JpDDc>C{=73rp@O%Vbe&C!UzW zo>O?hEsu1rI(|(sF*PZjY_0iWAQ5ZWUZo-Y)1N02%L`E>RstQwm%+axl ziQGeW3zduG;e(+8dAnM37k`S6=gGU}iKorn;1FbfLpHIHu>K!&Bl08KI6mqyGv;MpcYt{@WwG6rznx$>G|5c_Y*o(X1 z&t7%0q$qeQ$kX5k57#5wl9)uW@2+bSK*mBm+AeloxyK+nOfSNzc#8_zd0 z<&FklcP>0L_<~|60UpvvPymdJ&>*?wdwCQi_m)|8hMprdi{$eG5ULdB!q8?upAbbR z{_3ZaQ>FGUyLDQgLv3Pxq4Ivk9qVI^cIZFn`XOV+PskDB zfGnuh@7upOi~J?PTaa9Bsf)+&E1skid^SI4+T=?9SbbBfElp0Rqc?{o9D97qXK`$7 zP!tqYrRR<+6Si8V8dNzcIPJM_Qmb_xv0@=7<&If1DW4W<3HRDTeXz3nqAL6n9T83V zlK9-%dxzZ1sx-1uAPPh5Gi1rU;`nzih_sAH6!!QwYm4Se>U0F=)Lz(> z>`+25X2G?H-ij7;7I5Bh@yFcQ#C()3S(q*3PELhn#Bd45RkBb%ZP#?`9~T;n16*Z; zXib1}J?$UH82+6dTZSwTZ8KIF=f+feZCqEYYOZJhv!-)!qw8qx;Gh6mX(}hvdB2SF zO9{HlL7*GdU(EhOJLLWsr?_M;M;LH4q zCu}i2*(4b^zCQX-`a2M5!>KvU+!ebi7P9lySW}@`Yk|!e_FZgP9YzB`_@M5Dg67Cg zuklZ2R#aXi!1C;l+AoqaKTJHB4%TzP=?^7BHVwf zXJVm`5E_!-r?(h5^dlY9a@fomr<7=7MHQ}f@$0aCb?yn#gcvK^J)a#7vZS#Cxz!aNi;u@sqszS1 zwwc8nJ6^Wfzowa|+FWa!YuiZvibT#6U4eBY zXv2$D_&p;N><0_5J04?09p?tSFpiFPq}(>E(2BDO`4a<#a`;AIL`BDFRQ1g!XCn)J zoLAK_?zs6NzoW!9OM%47j7!*3^GSsnyoRx=Nrz?nkqFuv!)iKB5Q6w38$sA2Q_QvZ zq{rX8NZ$1W_OUppAW;w66%NF~Mc9Xqy44(CU3hTrlSyH)W&3-RH}(_Uy>I?C-R;|HHM9m$Tm)Q=crR_({~tN`__MEMJUA@2p8+f?2W8oWZkb989`Ud z60aX2ty10BGMHrF5{hdIvOl9xT|VsW5j!{)B6TxFDyc#;$siHw`EN&Cd9Q>9<_%PHs?tsZM!%7}2h>9ueZR;>l!;x<} zsk=61IGufq{UNQb8qXmb%cg{9N1a$r5z(bcjf{qzgvLc3fweK-Dr<7Hy&f=o^>y2x8pXsrwh72B7TQ({)TO4$ zl&mewx^cCTz%n!uMo78-S<-n0b-;gs#hJNk{uo`9FLLI3A1UtVM7|jVL9_i(Y5&ZX zHw&g*B;ESz+To58BR#$3UxvK{=Lk;1AuEm#>aHD}upIar%!wanycYIP{pq-$BsGbM z+1@ixYCW5)&Q;eYb&z6AeAf^*XLj+NV%kY-;v)CaL51z+t_=@RF12aHx(7Y6FTueQ zq~ap%zr;55acv*!<>5JgxW!G@Nnb(FW-|9!Wu-b{R`}^=n$TO(0}2&9`p%jHH8=1q zaeQNWwvB_yF%hC?#;<9QUBjG<8dr!}HOrqs@Q&~CdNC)On|r=DH>GxT(=E|w=o2DZ~*tJlZ17iy>`M+E9C9F#Mu zvuP`~qv}iW?I#$y53H$s?>lz-bzR5UFOl1dZH8KLbc0FYQl}nj|OGin-+oZBow4 z-Cdcfu#L>qJThdUPO`cFAgmyMe00SD*mBj#2yNvS6w%|=nI3NH57WCAGTAgu*e)}( z@i1-UWk2Avb&q9l0tKu&fUl|D#LPW3X4&v2gz{02zuW$fuF@I!b66Ty`CXrneYPt1 zvF;FO-6|TzjYOwar{2W|!T&`v;VYl3b4s~=-L1AVxuDkqwMwGdFtUV6S)rZDZh8ei&)qSjXMkV1-0kpIx@>;cojAsmYGM4`5*K~A9ACT>u8wKOC-+>d@!E?9*O@K&$hc`jkE-2 zU2Yu%Njwz?9o9z?%@}Yi`g>ylUGE-9kO0Ejs_~`O+u!-3PDsKX{ziLnkMfBZlVo&H zgSeidh?-&78{gi^4{n}s0}@-jpgm1P6+Qq!Y&Okma0p|;4X6m<$;RMTP&IDZmcw<3 zM$W~r0p4L`t++F+=+b`c+%trycC)7fzf%(srfCCGXJ0T!2+%M#hM@_)A8FY(hxP%h zsYj!r@DnH*vA8aXXiHz~;7Lg*n$0d<8ew7l2{!fGO%ZH04>2XB*0at0z2Q2_5&FNo z4TBBX87HVAh(I>;C%aiFJ=i;Jq>ZQX^^j|6#CIWZEh|*Qq<6S05!}@5W|JQEt~g44 z9*)FjuDYZK14Q!EMRoCT1hkbW@)OqA(@+i{43cN@fz=)1HrFvdv_rj~HQJ3nv{LoA z69%JQL{eoKkBNO6V?(psUUWqU*1`(i4_mHmC@Jyba9zzSvHwFYtbnI&`z6o`5_QIh zRpWYMg+89)E8NC?MTFQLdpC(*lVk+er&MIBsalErr5XBB!w%+AQ|qfVEcm=AzDC`k zXQt7j_8H}K4S(e>2f&I$rN=^$f{k%J0-m<`*%!8~00+_-iYrGMP)MN=~#$VG&3m zbMt1q0hxjSvR0`~=HPAiS#|Znv(&>u?e%XZSuwJHN&oFZkdBe%4m;Ak`5K|oJ#+Pe z9^i|R0Wh)Gpt^+y#?bSM~8cLF{Juy8fT`yG}L(2t*-v}|}uW1Bp>3vwV{rT0Y zS+VB%*^SRHO3*IvzM>cKe#c=6boS1Rf!^Eh%2y|xtMRe1utT)Wc>Vmot7ntxVM_aM z<7Uu&W>Ed|yIlKd_>ratJIUB8!3#TK7X{Vld|EZ`d1$4p$OdNE2sH4)|LTacw)W+k z*$nJD_x}j{?szKS_wf>CG^~iw$xe|nBRWxJrV_H1?3K(!PKgvkLPn95va;tPDzdV& zSELAOSdsc&_tEE_b9jA!|Mc=YJo)7B?`p>8@*5Ye6tY6F(7Pv^sG3w}d+QE)uvwMquE9pGiq)gr+wV zKZemjhk4vC$ROgKMGU|QDkA=DogMKpo#hi8Om!Ma9vIeFOScgmFQUcvL0-W?{gns8 zxq}q0Q++_KaaxA`@!Nm=i&P@^6wu(YX&&^M<7TOSK-0j)VUKb$GZ<^IVm)U#q|)y0 z9DtmP8HPwTdFsA++e@v<3xWN&NEX}#nw4G0^bDvVAh7wd9$mO1F;F*^L=N4dbo8UG z4Glf+-cKvlZDKcBY-8q zRZJ69t9aL#v`iio@opCgzV!uZ1+4)|m#cOE*vV@J8H z%q8%f&4?3AGjM*a{jxy-n$qUH9Sj__SQQJAPN!f-yVSXe3@z4+?0GCcy0IoxQIH5@ z2p+IV)kCL|AEZu`rj#?lNav7je@uzEy^rB<0U@kqXtiiyU!3t{)`Zepub+xoyQXJ#tQs8z(JWgQ@V0c zncfx3WY3#i&&+$-@0}U;Bz|s1-q{-)LB6o$HR0x$q-Jpb0LfS{4Qb%Z$?%un@D&X` z+@ShELtd;mju^$@^?-rMs{IHtCj_d#RVMt?23f5Vn{3rqz=oWuH^9F4dWujg$pE=P zccLezC%c)et|Ha)pUB1`9=Ve4hhn_AMzVMjkOm*Tv*ED|L{f;G1y$Hn{@M(Ewv>Eg zxx*ZF7lwf#D&BhTzpB~(QcUQW!rHj3W0r_o}W0g zu%m|ue%Gstm)m(to7tdqVk=qr1t4$Z{)h317Tkm|QYAVkO+t#QlGH#vfvlKb5+0BA z&SvBdxm<>GgG8$VQffhR?(fH~M z$VK@9yv+vrQz(?ND8TNUW{({kC-(NiDTKK%=~t;XBdpFajm|ATN2#SN`xBdq#2;%8 zYB=_s6$3IhFUekrcuJ`C;%G>z`U{>SBzoX*A>)p%el837ju+L4$8ZvcoS-ZYZ0b8Y zEM}g{vyz~ogNSHFh#A`>$_hp544m1TgSOi%LpR-j3f1+GbG!0;Mxv{89{GlLj%o>$Pl30=ml zr`2*8e$)uSemy*pl-4oFmlVm<1Z|)zAKh`XqP(dZ^$o4VWl$j=wsU-F-+|xpk@(Eq z3>emSS6jlV0B2fCUwrhqu-1hYoJQbzI8oZsLHJ9Zk5;Pxg<7w=;wT{m`}d?q2H-^B zz6^*LXG!)V79Zql(Ot=krY!y2LN1JI=-Dn6==8r?0R4GlXdP8SP!u=d!tvC_7GfcT zp^drQ)=uk!Pt2RHAU1sl9wMN# zu*WbirH~$?Qz2FUC_dX$F9i_;mW#ry<%z^`1w3%ZD%U#+%$$lzJ8E!Y-6JgZq@bX? z#$(AVKg*=b>Y^-(S|nEnzbTwNeJ5Z~R0HFbTUGfQm|>SK_3S^IEZe-#&^5InhV_5T4(r^r>CK?k{~gJ4`ZJJqof-e-UAP#LX#{$ z7XKs2HIlmP)qfu&{DH9tR_T9?qH;)gDO}riUfPp=swrr@1P##+EYJW@F%?Trc&p~D zvAk6}v*jhP8^i3J>ZJL%bHK*gi8II_HbWVNNCTaEGc)sOF_8qKC>-v%gaPFFS8;0S zoB{(1c76uB{8S$uCn+OqpkcAXx?7OtX(oe6bNV|A8u#?Vq1|gY$$0W&h}0(gqoPqK7AM(@VfL7@oi8*= zM7kFI`S8^A{qR9i6lsF?hsba@jaB3duz=6@0D4oxdrX1nXp!j04uC$^(Ijay3 zvj`7bBx6V2HkmJoU%nJbJgv1GH9yZSM4=NdyxSo`<6K&mh$o%IbQ743CgB~*>ahxf z+t*i<-fV62m3W({VVf$l322)9M$Z{TKN=75r{hSTIwzy;D?WD3V_WqF}d;M z>Rtn$SR%PX{hB`by%laqM%kGtzrbzvF7wjoKND?L;{qTgkh!`EK+y<6oaUNrf4nY1 zj~F8kFc*Gm%=0QC(MnBz*R`|h_|D7V+ph#I52U2s%YQSW0wHt2<-8My&RZ3WUM z_gSncl6o^KnT!zT3&sdJySM^>oUwD1) zED7$ulgs_d+HWs;g^)?VO(9oubx}m5E2^vLw%=2a6-y!(G(H5>brk4FcyXnWleiSE z7m@wGf0UbY)yWc+oGnhj^$p&N(?trvT~S)IMEMGd2~L4H;o-(=MF4#?4sn%1NyZJ) zWRkEX3S42^*HSDf@mdQ(-IQ>7qgrpRF6|OAeR2OChw;XipcKs~@I1fRPV|+Ql{%cs(N-nTQ>P3Z>BiIRaVI5T&9~qu9Jl z8Hq#^c|yk!^R*W2er8Fc`jub{d=Y(#p!%~Odk?c~C4M7>ZvTj|JeqdgGz`ASuR{1< z-$M551e7rS_Y`an#yC$!8rLGn#SvDmWA2euCzFhS#0ffNecF25Oe7gt&s{jm5+FuFB+rHh{@I zK0&TJIl@v49jhWsG^r(H?*)B?Fe8MphSH{jUrEg*Lzr$s1V5}Lfbivl%L}1u6xydy zOOpCnPqrN_+urLPsI|_DpH}(uLFFTK%PQlDkSsmf~-Sg&bheH8oXYKlL*TQWvRNQ2Ifc9)eUj z#+rVKs~}YCm;?!190ay7wL`f#>`o-V9re zm=u7HJyQ_!rh&a?H}MpiJ|BS`E7-BZ2}nNnOCQCH;#4nP78-lTdXkSzFcUGq#Ur4& zr?r&=Y$;L`!?XaGnewvV7+<+TSQNJhH2oPeLa(zRJ!MjnH57=NL!;rq_X=F(qV(nK zvtnpc9HcKddw$}x?mN5U0R$ixxeKQwol*T*fs)+*15>T<&I2Ylh)Q^nEdv)N|I*n8 zH2C)(l}}5r7!72HSu&pyLxw#Bt2xHZpw)OO!TC5R;b>i{%Sz0!PH+Kw&-+&r&Hz{p zH#k{{l5^Y=>%Wnrj#&VC{M3QAs26-Iq=r3-AA0f)10;%HdKZAq@lTYAm$+27 z%kT~{MOKC=A{6K2GouT|E<2O>vQm(Wn{0E8-UkHNV$>-a;;Z(|GV}Uf2&s7z*gnIM zy!7FxJJ7WXwamyT&ucCCb)Qs+?0~G|^1&aM5j7Y%2-xUH#&3#YBaS=33mk|TSH|Rwo8xTI(C9Xh8_c0$Bcn|zwY>^->yNE6y?f<{n3K`FhC2l#hFq5UM> z28LqisndwnX9>0W)`F!0auH&by#|!+EcZe78QPpc1$v0Y+ek*Nw1-0mX+-rDv=-Qo+sUpZCZ-9YmWKIV7(6A6gPs-pYij&EAI5n^5NR$TnL7yLkI*(W zyCq7HU^;APIZ37sJIMoX1D(ZH0Jmwp%eZ~V=1DRQKmfdZEj;-~v;$pKKAK&K&(>9~ z!K~miq?NQm!5J91|A4Jl_#-b&#(d%7laD0j5)FCS!4~FM!SJ>Q?1098;M>;i`vOtU zV+F5Op!=f;r!xr&u2?)oo9!bKD4PBE91(A8K(yICCLJGJx8ex|S06+}&U$pi$n6t@ zo-?iC43dh=%CIj4CGp{7HLt`hh(0zN4m_bZZVS^(Xmyl;CkiAO1oTq3Enj51LW}dD zlohs}0)Ape3@gnbU|r#~g>diXKhOwSzrUHj0500@N}#tZ+nKzT`vY%~9g3B@7yC9zOpcLWJRZDpxi^5^nkXzfXz)?H_4O-C#j}C5)3D z=r|SKEq(t42SEyFRKOt_EJD~5q`!3&UmcWZQJYMXkm`o=i|wc4D8d*$`W;Md8D_+S zipqd1*aeIgI~&b?F}+IS$%@|CD!|zmBWx84($vYGi@JjHTHQ0UGMB>xZpNnSAaSeH z8;OlFc`+Es$)TswXl9uDc5tSuj`Hu6&2ye2-XdzA|1S?|g|wg_SfUwDR>I9n|DN3e zPk9nJz!5y0e>BziN!s^&dsI zlu29LCIb|ZvfyuEXUl1wbGju<5>+`v0lnR1J7GTeJDQi^#W+6mN(%TNH~p3VG{`*~ zP}EezVxzYz;%g53Rm*ibB<@)idL0FMy>2W9M8Ww|vH1tgb%to1`@f&#rJBA{1~&eJ z;!V7ouJ#3@JS%iTLNUAO3v$$P_rS%Q`fBlH;zRxti6x>81G)>+>1hLmPo86;oPld9 zc&%b#K}3X=G4c*7wjg0P8G3;`qDdrON3{HKtDGKf8scY2X?CguQmi|(S&3tcmHh-U z0<^{QJqO~aQXSF7q$_ok7^(u;93_l`-i}I39G6SwS7*~`!IZvE*F^fa0ofTp`7Vd( z*m{!2+ct2F^cHyzVu86B_Y3dXENA&8L_R)yXj2-=#80g8anK{TMP(6RrI2q`F!N9y z-D!ecl9ik(0N#;SEkOyjDGXvwxfR)`X#el0Dxk8n? zx+OGyMjRz2p>9I$dABH}N7<{NBks{-3Lu4+397DZ%JT9^*WRv?I6XQ{szLfeZh&)oDl|%heorm#x>R z;5lh62IBy8edZ)L??>Ebf@^w!=X#W|X`6H8n>J#73i(MN{EQJ77s3n+nMXn_n=Gv~ z6=(ND;!ZM1qZk1S@eg%T^6m_ULXYd+;~THJJ!)EAL)S#Ndnp^1Be9975Z=P)lra^u zp>aCBqlJZ}p+OP?P>;ucsIR%{BCd&Uo8mU#yMHFLlZr^3gj!&Q`_1F%&~YB2vvxYY zub72)CEI~cItr_i`5B?rEO9a+UAwxb);OxAaUbw4;EpbXh4dH5h1|eGdJJ)w|2o_- ze6iZZ4JCYnK!*)@#&3DSo+3)pW}|^FD3S7z0dS#QS!TF@$AWLIm6*g05{WfHK7eIj z0~#Bg!c1xVUZ(Wg0L)*3<48o>L<6O9PkSL=g7v`Xu=}9(OiW}4@ly`Bfx3+J=)$|W z9OV1@&ECv@-^dvyNxa+>)WSYPJ&F=4Vstw;Z!F=IKU69awnZ_DRA?dE0cE%DEV^ab z;tA+>8SZJ@(eJFpWp^j&6YRNvU;HGJ5TPuOnb3UdhI_0ArgDN|{^y+BE0${XOf+cc}cZLX|EL3OCRbLB_F=?0wT* z)59hu+TqzulJXI44X{@9hR3{!wdiqWmK@cbmk$;8s1K|<2&jP?6R4d%5qCW#ODM^? z>9WYtYnx&(6Vdd9h|Fb~T_)Nay>yHX{ zs~e_RLEgo_phjV=0Wy-fSfHVEY{Dhtd?!u|LQcK1QeKqndyaJLi@qS&9eV8)M2rnR zGO(UfQ!$E*7|1u?c#&TtIl0Cu?03Kt&n|WB1(x_M;Gkk^Z1;hQ5cRHP?Wd4G_#0TE z>M<(%I~&LjqawtL$IkIi&j_O3V~Z0&GQQHrfS_sgER67M`H`u(*l|x@hFd&5AFg zx4A+>I>(j+_!Tfh#s#?5K0^_4>pA1djm%O*?<<0Aogex=CqZ;0xGmBp)4Zrjdpm`k z;^7JAg$&A7J@+WLtnBHDG{{4w8x{+?#`NBfhzJnrkkMjzYpLk~0;=s4Cfi3h(s+ zjgj@VBJi13;`LL59>$-7PNc5N6&-_T{X2_MNGDG&C*ioW;o_8FyxNN`5Ascj1qDaA zspxr-8Hzd&Q`q^7=es^@lCVl5xn?j7tev2W?h)9HMNq|i(YDEu_g`RS*{Z(tFmzML z@~{Pq0PLv#fTvzn^!1%0mo|`~TMalu+>;^@DS=m%DJru4#L{BPx4l*S!;C5F!K zsO|=bD(AiMPKxqXXDmFyUd)`M;X=A0mi&CYJ?pnGf&))l3j;|y8=cUw(4U#>*1>}N z_6irv>y7sM%IL{e{}Jl(mmoG7>u|roX8eq{La7j2TFG$U1JqvHo}FXM(<=W#37Mg% zuq92qSP(eKT68EXl+1n95NESgCUI9V!Dtlhremn-6|PSLtQXgmt~E+x1VX&JoOYB^ zjpB8%HLdXItr1+!5l~kU4Ae!{ON;OdV_}G9w=alW8<1RL(v7Yy|HB%MqCsBNDlL;` zZSLas2d}i~Kr)OI6PyD8E>8=AkWgFkQ}*KPosW|~uiPzCh}Qu%6dSCOncITeFxVU3 zHuSQV*7_31KY48PHEDa1dl*}-;0oYFVGzSArg{gUm_~NRyO7^5eYCZ{T&qvbB|}17ooW;{PUN18 z>lim+c<^Xv(eYJnyN=@w(a}D>5#Ra%zq$*5MJLz=3OonP&v==VoEs&s z$Ccmx$F_g}pa8>0v#}j@*02hJb0Qrz+&G?TJFmHg#60Js#AcAQDC!Vm{WBpwXt2z_m+KbVGMHtCQ7^Mj6{<|6zmATI)twWH3^3xRu0rBBcDtv%C-W6v3qV4Vo%(b&um z8uo8lleJE73*Kkhwn~sx-kWy7*Dr+EVv*pcgUTwDJa~OWiHq5y>z7+$Pf3Cva%vzu zllN04gn3jl3i^n5&h1%U#r{_UZ#sdy^EjdZ%Bzdbr=3aB-UvI2IVy(y3nm(ylb3tw z9}I{~T)wlajq@h3C5_2DbjV3}FRj$ji*H-g4X&XS*gtl|ZG?w*W}WawXA6ocQND<7 zo8JDD)zX{&oLTZO5o-fDA_&e`UN0}A`4}>f{L>J^8Rd_^-kCgJ|BeJyxad=Ez`s); zof>SUzRDx@BR#^1bv4mHfYaGPcLv=xIqHovOd(Z*+AyL7br%x>+XB3vbZ;EBJOwg! z-dDKhY`U=k+{smG6-Q7AAJv~8MJPAKIK8ZDh`1?1QNU48PHY{q4*E0*SHR@}(ij|7sS;w*&)Qs>NId*V@IBK-LE_S?UojnjZ=^%xAAe~b&FirE!A6@I6DEt-NLH>^L!fM^<5@yfDMk@E+Bj6U9eRZY4E z@=ehZi7rTe3gW6Pi{nLI>yF2J;GI?8+K|ACgO8J45+gJR&?H6|-3)T;jDiDmj$g6S zdse0NP_g^jh%ibUh$>?G>IE4Wwr;A<))Oj8Jj5V!8h&2EUrngDsEWDiD|}KTzJj6| zU!Xs6%kRm365S)54lbweQPe3BR{{eH&+LkwEcrbA`i_u(p2B)!HXv#lzeAQ#F;^Gj zZ~rrDjdan6e+LytpJ0n0^pqN~`ZXbh(kkY#~aR?#4PYN{sivf4n=$xIN2w6UoSFv6wW97^wvw zUNZlk9k=VogsFHou7{u3t^B8y7BeS@T9!t?cE48HYVZzdcjLepQ~=V;r@a8 z_pje{0KqP8tK$GbrBX$AjqSZR-maO3cIlf;=#@ABf0m}Zb?cUn!v}*mnFr^l?oF3@ zC~qBH;~hZ&$68K+uy+Tk*gLA*Z<{SfZDjCU1x`N2iG^<71j6rdPXF5W%W0kRCE8>3 z+YXbUjuu;C56E6pP>virm9btS zMRbRD^Vs>BvL7Bi#llzDIru=|PC5eCj4^|bMK=4sRoED9I4wsC%-0j%iJ6Kv z%WLo}7<(P4zAY1$b>;Z;>O;3y?XsUHczUKkn3b@~Z@w(`N2k?oRa{9w@Fr2q+Q2BH)p9K+-$5NbE#|{X5Wxza3KlxL10CE_d}7CE zkK2o`CC8g#v#fG-zKGJsLW53H=1~ec8JVe!mx$B@{C6Z3EPa`i8ttXxVTDcW*VsFM z4sPLDy}8ZguqKA?I$=#cjFyUx#GX9!N#!?hYuAQhy!pgJ4oI{m>JBg&}?PB9quN)g?BX$l^HQ(dYoJMmRSUor90kf ze)?o()cw%thgNF=9>$4nDPUw%Cvd-;;9$V5Tf$M3L6vfN}W4 zTrrPubA(1RRy`px^Ic%)&$RLJPjyFUNM0}jX_nY5bWr{n@M5iwv2VVSMaw2&Ma84I zwJ@V`9oDBemJA?lR%E?(e1EyBRo&ah(7tEkQ9ksf7HAzA56HgYdfzI-hw%&!Mf&$t zt$UDAYpxP%jWk&O{5eU2iWw9eFN4Ai#v;;K*Sel9cCv26Dq%iM4?IJ+^Cp2-6V#Hi zjo*?l<~Kfg4Up@sg4`hnfEmWR-67yb0+*!wWBtAEGcJ>1iIeTOY1gn88Nlx)=U*K+`!aI^a!X7$&UQN#bD5-MBC4iuE+4{@LftT zoGN>zX{!li!>rh9sm2L2W5#xV{IhO{ETCLNi%ckDT%t8UAw`WbqSb^|eU&?80`7-I z+WsRq5HV{tjc?~aG-%$5CV(h_Lo+#f{o)=aJOx~8dy2dyJjw4UjF^`l`|Cj1QrNEb5klX-KC^TcS7#G^{Kklbl)f-(1mGDmki3mBK#N^( zF!Y|U_2cOwmzpnPdsVCaLjGeQ2i((dKv9f%L(4pS$N+6>^kpNI3t-U$c@n`NFk zu-XjcvKWk$?K=$|HzH*jc5;10-OLTkw~x(L!vU?W4y)vaa_{<3yoqxW%RaeYnTTUKH#JYp+g%K5GSQ$i$Mz7b6v+@2MMpX;J7SMXgjqcqF*3~ zW<(Dw;k4MF>*pbnac@aa{J|PD=%maF(d*Q=+K8}3eiIwhI6IjslWyJ8bE}5Z($&j; z4I!r7x!!ESw;{RKBXag33V%ks^BvC6auEo7czJ4(t)KH#nn zI26_f&>>ABi>m}N_!H?OedRUx8+BQWHN=0nAno>+gbNOYm5%NOcx*aePTiC8rgXjG za47b4(G@kzOWhB?$Q?p~)k;4JTSFtkSr%uNdoBwFM+HVl z`7n_B1=thRuov$AdvBmHM^1Tg6T4c_*e$K4*@U&!M41IthY88Fj7^_ zz1S|QbW#<1KvpqTFoUtXUvNp{W=iye42a(L6#oIm0ELf^N~W3=mX~LLD!eBrjVWlY zkk-uGTk8fU3)jS`lUWsIEtTth!RuaW{pPhKb`RqAwh>cd#O)_&bm|WOsxtKnx=xmQ z`dN_Zkr}6Z4yytSfS>3k)%Ipv3HJEB_~`q;Mo1%KY#|lksW(G%7)4drwJVYgT*qt% z_Nw^?KZ@na)5{H2h%H_VzcrNbo~aPe24wInS2Ul&-4FP&MLnHw)wVV=0-h5H_jH^fFW`Vty<)YF_ zDQ%Wr-D`aJx*>e`=rt-9f(rK{lj@n1mrgL0n%FD-{v{!1TOm8NZD9XzEPQWvN0MMe z(LP?25*g$*_j%;}(pPYliIfb6ja;XHH(SQR3j~nPU}QNthYVZeFMb)|Gswih5RF+C zkzCh8z<7*}N+Kl8lF?`yhos8&woqi7_gSrlZC$|j#>u3FVSbCmWKwFyxQ0mEHF%RR;!3?4}83Xz*5rsb|q_O;3x_r2h8^o$_C9D|QUB3y_3@ zH}Y$yYmwVi{`=5Dezf_kc-sQclQ3%d5UKPd8HbY!#ALil*y@lOO-b2A0C?c3Ry1^ELRBpP6j@bwDsU2HSg> zL-DV&9cU4kz*l3WdR%YC{@%az{@DeWLwRSyb%Ln(DdlOXX^-y{tG@iZma4yDPf>o-5+y>pEDz#bU$k)<0rgek#hn zYatjjNai7t9^rgJF|6B8Gu8Z%Kf!ilzfwmG(j9-0tA3g+3GEri!rDG?wyW@ZB_l50>jVL~u7lAsU-Ys}`9p)B z*Z6+0Ws-3>6Oohy&zDo_sLP8u-&-sB_n16048`XcQX)?lap*Pcfr9ASfiZ`g-gx_45YrvMMe37R zq>ycLeOc483!iPkS0~;1zcg(ywPfzVx8nLL+vKH_Jvn-x^JJ7*bEhnHeO9UMtDJ+5 zQWt++zI)=z`RjcPS|Y6-LM-11;J?@>tS)`zZR0vXa1f)ZdCwj>81VaYzHef~S!Yn7Odc;Agn2n))s z3N0T}jrJd8FuJNQVSgu2&#RpGs(qW>p?y=v5kYQ*#p0MD63qOJIjy#7$Wh9)U0VL7 z?n(buIfUPgIudq16T0~|E(}p$+0$-@reBA>H7F982uDks#WdE?o>``EDHR^z>5-&R z^}3}{MWU6H)M{ajRWKKf9tT|eNOy_xLythp3z3;mugYl-w9eU6JtSC%O0~hg>|d`) z-+1_^%OlkJt9LBtD)Vj38&@Z#dTp5J`E2;}pEb?r@OLd(ItE5gEc_OHAws}+Ar<52 z{jaJgHcRank^i{Wvvmzo6Q`4h*+i z)%v=};!cXO&%fxjZ7KZm=5%J>*??6j>ZeKMd097azKhP>1lL!qWriCguzotlRf*Ltj}biKW}K#J4?~efud1Q{CArDujkga}Ov6~4@+T;=gI?#=pMUS+yec;vQ> zcK6Y;yPkUY@a5Z?Tg zK)%)HPWYSsNA?JPI`B+{jp0mXv|7~HvNLZF-Ijj)ee0`K2SQRB(@W0gho`?_{Vj3K zzLfuGSNk!ORY7+rL0i1Z+{~niOV>N~j4q|Wsz2eadwcW{NBy**`ppXj@p{jn^_Dfa zaemTP$_@Pd#}_#oC4|oFijnShw~+AEJy)LD%tQH#sdMR5mMwFiTBt*(1=aj6FE7NK zrE|^QFyV%smDE?y=ysKyGgp*jnbOtn(5}v}Ni#J>9NJa-#s7Lf47>W#X3_Fj!A zR0BDAP0Fqm55~sTZ~gcSo3Y@J!Iqa(Jjom0zO4-nSn^1!;)#e}MFY%cinJ{~yk*lq zY@Nkjv!gSzGe%L#bY{C3)$Gdso?&n|ui|@i!l~?TU!43~{;c zzwDf0>d9jknBT3J`Y0+rZ1{CVF|^PmaJB03c~TM8T&_GF@aAE_m$+Ext9R|Z6Q`P4 zOPASV@EERdLB*xX-%mvrDb(+KPbwLjDZDuvLy~8ubYE}HK5*p7)z*YdpUXx)b%mSn zRqmS_^k=GsQIrYVUv9ZOF6h>}OnfnO@n*im<(fvJL3o3J4*_~`#aeTG|0%EM<;jayfAFO6E za&mq36jd+xtQ3_$wrw6wdSUTRXQ;znziI|K{^i!-|Kn?Q3x9(W_kw zObvL}ztU|U9qGAu>ci)mcxt&(Z%IZ!3A?BM^WcI8f1boac{tuDZH{61z-*1?vl zG{Ec`FW1t+e7lVhozkk4wSV85Gad0wW1Lw+1 zTZugiO7bFap%LNn*xlLJ>Ud9X49%R2EUEQ2F0!436;EmCI&M~q4mo((k4cil?<=h^@(N-2YI*u71IRa^+ zK1ik6{&I@UQ?_cW;tvkX(8Bn1o%s_=QsPSmVn4`p^9S z=p;4Oc6!#;b8pg~_Gv*1zcb6r7ScJ4{_OH=pGzO&o&7o%Q0BqCpUa^8p=7GnQ!_}Z4R zz}{axDrE%tOnBm~h0EeL7+RP&K?;cF&jjYp;jFjn2S~#OsI&bxpHi8)Uc@mYSH5Q< zgo$nJlu4Bwx9664%D)5-#NvJ@&1wa9P4Rw-Y(1y6coA;wp!soF_s>{lu^UM`ZSWcn zl2j4ftNsjI%KzA^#`-+UP|aRqM+fJ~owK%*(Uw# zBk&ep%PhrxxR|(@!PiCz|NggA5YLlX3F~O-X*E=bDRWeOoG|n7>#l|NOCL0h4WB35 z8^>5upbcTJQsj>{{g@s<>^VVen!;VzuQs0eW2EErM*CmI`4<$4gO@=XT+$Iwt~#&z z?$)QFV(&dtnfiwS1zl~FZgx-vNZGCh>erd z%1bVM?AKs04>7p1v?sdpY6@G5^}gri98R|lbJ-E8i=^R*%W>dut&r!Ysy zgWkQIQgi9)l(kP5o$6AtlCU-@;QvOlW)dZeF>3s#LMOs*Bpthd#VVEe>Z0W_uhdiP zrIcDEuuY=NilE{Y*|_rsiE-Dj#}(NxFSd+0)#e{+O`0rXR+`zkrSCPvKYk+o1y&f6 zs{wAK>Tfzrsy!XP(e)r6c&NPhx6|O=(CICuhXj{-AZ~Ge;YZ7%f_tZ(a;n}#Jahhc z%x*q|PQ|C~sn#j{q>@=p^&vUs^X7ryq4WjAg(9!*?@j;CC{UOZ7ozj7s9BeLl;Q6d z9Ugn1*){k1{Y}1y0!t@cz8__o>68h5zW@0&wvi-x8^&*(-Y5Ohf04&>v7P=xZ%j=y zuoo-CFt5;HiqgWyn3#>rNleLo!OwQlrineu-z9r~o>S()Q3aVRcOlcaBOF80zBXu& zgPa-5T-yzB`x*xPCS;{&yuP>y=C;|rz`ndUy~XFC?O!#LU9o;C&f)$;b)iFv^CEAg z3#^A+y+Kx)cU7Ds<}DlpIrWbproTk)p7^Ew_D9~8U30gz4!`%Qt$PE7y^#-cONauZ&+Ikb zdA+-PDX~Yz9ck7bR9gno7?Y)P;V~B{r(N?W${X`lyW6|*?b@MI1}^uHX+3e6mt3yE z|MMS%C?#oVF1D!Lt(*OMIfyOyr}9*nY)9f$o%kJ!!!e2W6>lsF*`ReedJ6He2bxQ? zmyY|>@x+{fY|5;C7eD*{veTRi^D~BI41UX~Usiq?Rf?FceyZ*>mR;iJ#noh2GH0Xh zYL?=(2EP}EuG{`0L-;?A^^UJ-si~=nV)e){`Sm`lZCUNp?F+qGW#+OHd9RBc)3sQmrKi6JKy75{dlGV?n6h~ zZTfP$kbQmEcK9-{lr7qDGCu~q2%m?@@0(X1xuk0akE`+E_NlZ;1b62W`gpv(A9#B` zbWm%{+!~woezM^R4bswIPR7@$uHzcK1F! zPU2VyotLSsB(lJim)0wK;Z?7`Kj8mBqAoh*HN@%C%4TDGg+YrWh>av7S6z z%mzFS`{qx&X2*k$of}`|eU(+zLq})0vRa1ar(yi?v1ZW70rvuK-FvbgLfV^Kck|%8^0l zxy-(R&{I`R7DIEp7Hp(1N3-tY^Jvcq-5O!I`_%Tm5Aseh60NGyNRhpjBRJ%4Z#sj4 zxLa#0H=yA0cAM|Xq2Eu3C)&Fg(NF*FDiXmNu;!~WloyQUOb!f>`BTxCU-fq?{NR|X zQFQy~k)J>r{Kkqx`pz1Sj`@=DKg)cG&SckpOr@ z_sB$Fp?ZYV=>Bt?(kD-rUO6`A`R=~%4Y9h8XJeIyGnLghl@=CugneFCLX>e83buQ- zj>Jv3n)mn*^yX^%KM*nKj&*&mnSMsXyDxbUk!G#9j4Mo{F|EHh2f?-Pr~7Pr*}nbS zGj{_5(&BTxK8@|Eex7n;id_k6V`j^>v30lviuSSSzJ>K8#R=P@@cFXb$KOVB3%-?9 zv=X(kAx%Dj5BNAmEzEzvtWZ$t;cL0qzQms^znuG>@tP>@6Dg2nzm`sLLmDZL#``M4ddIMR{HuIx6n`V}B=bqeq8z-eYg-$M$ z<$qYIFe1|mb^V<(RXb#VWKNr*xE4Y*5avlBSo4j6&)|=8U&YZ8y=5p!g9kW)v^Vea! zE?*J#twixiq>hS=TUef&PL5p5>_oWBr@8PNepw;%>We86E5P;%!s^PE23-AM*Hz#y z@4iD*tgO6PMf&_CN5PZBPBjL*3h!Lzdx@&QU|p}DF~092t$rj@Kl3dIkJmQHams*G zsKh)ILVaIgQwmwx-=yC%Y2p}8^kykA;FownE;APllN@>WIL+G}GAt{*J(@87%ca@b zE5)Lky7Y6*BO1kpBCn@^^*Jt^VgA&SfZo&wk6(MtJF<^|U-T?cdX0aXy^}(4IUlmD zrJ&w!^FZwQZ?~`NZ`74$t?Kt5xq8Fqp_FC@eqJ>5^C5gz|AoG%Z;Y(-?Z=1T-cOl> zeCbK;8~9}^(6KY~q zJK<`?kiwjE83N!B_aZjc40>dQet!3~ra?P~r(tKuefK(Xm80?1KTLwkW!0uR>Y|Ku zbgsFuU&}sRIyd?;^RQEl>|Y)0{k*~~P(OZ`vR%t3>eop@)b(#0TgpT4Mr>r4QYve= zy(?9v@;}a9jsv8THL7>}!GnsahG#WX&r|&TxuiG}Wc^lJ|8D;#rMp75iADp4**1MH zN{555%*RCRe`?*=E>&JKV;&gc{IgV2$>`C&)4P{9HgX!H;E|_sB&;{>?ft`J@i|#o z=&}kG5VKZl{UURil))Ci1ulyyHgCeyy9xY7Qs!K_J8`{;M2blE39iJ9AIQ^}Tj1Vh)Ex)kHd z|4r{*tL&8iVIxN;&vYo8Vdz3^whg9ojr{YzXenKB8}uZ=uV1W#5`*C>Q9BL2+H-|yS8+EPtBWI zt7*PGE6(9^FRgCTf`UIMD)FW)Un?WB-SS+iJ`GEiA2a`Dm{Pr!&Gqo0{e!Wc4vwtL zo=03g#jN!)&5y z_!nb((k-?ONaY=VSj~CGNHe#$>J7VYO#sC>cXS4b;^GjwQS=kAH6K|Xs&Z4G*;y|z4aTv{7;7bA=?G!OQ{5w39}qI z;075xlj|x2J->UsQO(wh`tjx42k*TjAmk@*QsdRE#nZH(SrqvN9-yLUDCj6r|DEY{ zyzEc0nvrgfr}pAwIcev4t!nkU|5f3zapY0seu2*yEu!zcSByB-RPH|_q+8x`YOrsi>{KyhRZ8aO;7?!5waWEJAK5c&WoVwV!w>D- z<0N^pS>TasQ(zn3Ff-y!ce>pv%zkF20jyOk)3Rvem%&wg-wdh#gAEZdf_{kW$K z@k3`OL-S1Z&fd!qv(?dVlu1v>wQWA(!prUNg*mWC%SQF6U69SggAbj(?!0@CS)pv> z1%nH{@&eeL3MK}!-;Cq(t-DSnU;XoV`g4E(c=3d0Q@XrL(T9n97r#Gx#v{%5R=qj& z{M!zXT!kx-#LjLwT;3&+Iy)S+Xf8B;De&w6(y?R$O=^Eae?{kaHYC2IK6UE*=QywL z(-qHNE!2k1MCMtP_5Q3c=MjN?-T8vW&V`>LG@0wQ6^-?b})K%0u_nfstuL+rrkdQtfR_ z9G5Gr?N6-m>GkIyY|E$pt0>eG9bxC^P4s($wq>Qn7se7?@;xhRz@9tnN9VGmzS zjz^qV&%F=D+wj3xY{?co+)I|YQ5*4pDYU~3HFfpD5A5-rdS+_h(yk9H_qnRr|NQ#u z_vz;9o%!q?0nI(r^=xB*dYO;kyAVKKxQ#VQ#A~W8>*`$m!{b?{H_YwAmK~3{QmVEN z)2YmbAGt1R*OfkfDf)K)=j3p8lG+d7SH2Q<8YLDCH{9za-ry5+&tFj8_A!x{Iep}8 ziThs5CVT=(=>^zpWb-%tzGywBSZJZN5_u;blzn(jQ?HsqNhJm z^IXoM#zOr)ID?t~^puCYo*nUe@J$7da(FHu<-pMXKdP?6A%_ zh;&Ies~{z%bS;P|2na|cE!`ls(in6%EU|RM5(^8<#`l2U``+&#*nNIAbLPyM85V+s zb)ukk*ti)Rwz`Q%Xlpw-EZ!ww=Nl69x_3i&`OIP*8ZTO&mO|J@6YegK%y>h4)7ZS$ z3x+m6xv*$~diMU^E@0yTc@2kA-#isSm)$mZm|oe=l9@?*v~iHP)YfDjI=kp;oT|}n ztTJ!vrb#~N=4FZ7?`_Jj&x+U+Ea`x)_pOG2cWQj!gyjE;SOO|;mJ8d_7OMGw1qrFu z4V)IoSpFREpM+ihp?n?gdh#VYK8lxntUXCk`<3cKP<4gNp06!r#Y-zkZ(GaaulVL4 zgP?tutjfLaWu%7_Qw1_jaOkVU%+zqWzTSgx5i3i2kTN~7lMSO z*0(^VqWXULrE3M?b{oDh4Hz;vn!(hln={q8^LK4g_Q1T`&nf;8aCFnG$&0@}^@is~ zJI$gz!Fll~>B)(L@B#;IYSd_@GbCN00T(*5&JK+c(2!Kh?J;KdIw5511&*Yo>I_2b*&`OmCyym4+L?j1li`OM^2`yo#I;hatq zUSW-U^?Ew~SA1}QQi{6FAYr%}y2C1s|D;2%a% zR(115RI+QqNa9&h#IN6)T3Ih{+m3v##2F?vt0WM&oy)TR?iK;Z4M~U0PxP3#*=_A7 z*`o*Qn=CQS5Sh9vr=1vdS4DIi>8tKhDpage-H5EWRRq*@y-;N{H9G|U#we;gxy#p| zYE8|F<2=6%EVZ3PXB>m}RB^OZ2VgN}Cuc5I*>rvQzx$p1pm$N-$>SAb>lp;6Wb=04f+Cvq4SP z^zWy{C%BSe37Fkd{;RIhZnW6?3R0daEY1Cq9`Rs)+W^QZW2u zA2Hkmveu}+5jJ#^;O(5`lE$M6wfz(P40pAijsWL^n`(An$F_^3hb>Ur;<#I*PB-gh zeh=W;!^Y4KLx* z4lLFEw|-1IgOJ$m*YZhvBY)*AHPeaV7b#m@Y+uRCArbz|SajpggI9WGTp~x3+Qp}t zmpaXyTs@j<5=uZ})78e)UVv!vuQ&y$f4Oaw-8mbDK1g)`v4%oA)cyXM-=7}s&Kth} z%a7KuQr_*{-0CK~D^I7fAf^C*c_&WAa=hZh&_1#*J--Nc`u1|1T?*>bk{xYTh7$ zr*&Wtf#il0)wt0Axyl>I>1V3j`0J-y@xJ&E-&4ra%SVdMV7;*l38--{}(pr5bF#aCe03fbUMtlDu@bJKUy1- zXM>zJnD|=h$LzjsTQdla_g*0TVd6)D(POGr92l`Yaou#fRNyB@fvdFD@|6PiQH+%XB)u%X}A}vG5HJ2b&lwUa5 z^ap=tI!(_&x@0b84^k+O>8~mYe^qcCJlAYD+BWP~GUEvLU!W||`S#A=d+C>vh?B-= z@$?gunk_}7VTtbMNL;s>mx>rt7zXdN-qyPLUr5F>H{!I$i(jWT#LTQN3@A}Hd0!sJ zhL*LN7)hRz=PJQ{;PDP`d{jsTM4@17Oj=gJH?|)v5VhEke@M3$5P`nUPmk|7&$K6s%h+e9D*|)yH$&pKIupF}dH1J}%Qw zUwiWxDZzni15u1h5mw&+(->!Y{F}txUKexT%^UXQcJdX6)ok3hk{;`9Zyu*gQ9se@ z(VGhp4pShYDHHGk#T4LC#V;8q^u5c&D#?)Zb(4)`Qg!@t^qf8r87cs(KUWDV0$g$ zM~|MIfg=eIeD$pyyX7mV7Rx~fXRz%Sl)W=gcHMnj&3SLQOmR7B{u^_-#$Nva@-+8& zE&~z?R)JYxL$aNZdy}3e*BJ0LzD3vTvdRold=ko3b*RY1HFcgd6>R+ zSE=k5tb9LvRKX%2Q~t0xEx`m-Yy7K|MqrT`v1Fk_?kp*P<(=;<5`%H4YN4^GckNU? z%siTYZ^1T)-TX>OXNA?M+1-q);=LVUaXv(1|EZS;LLoqJ{t_VGI(s~V6xEe0`jfVY znrc;#H$-c5QMQ>VQK=%4Sni7WmhbucR3{c2xAvTWZ4Qri8ZCVNEre7=2kkerw4F=R zpP5}Q){qW73>+v!+Y3@0hLZSn9G9iVsXHXcB|JPXX4K`WWfjn;S&W z>ATKLwfCaiOEr&P!L;6psifp@#>)_t;tR%sFW~|fqXGaW*ac;)$ z^gnj@bzhj3o|QQQI6b4%&qryQMyRnkYT-Y&JJvto-Il!}^057k;$-E$KbuG2bO1ZB zT(S<#aj73T70%T7dYxOmR#v4zvm~^~tl>xb({C?yk!c6FBoD%HON{g~Lns?kv%i8S z@c?dQ0^)i}X`E6V@M_p%n!L=!s%&4~V$gaxwn9E8_M9Fj7*(qxtZe4@wZAQ)!Y;3v zR^&;7cRrh1o%{Gp@9h4K|96yK;eZ6FT$f$)lMnjhq*XKOTb#kzhpI)v;y-U_k>%)~W>ul?B;WKlGV}CwF zZsx5nV%RKXRf0$tW(`H??Y_K8!9yhxNdiJ!Yc(P$Q%qaUALXLlg zIMjWcd3}?V)$$MJ^O$MC0Iw{r2TyZZde&J6M6v2PapXhW$tu52nHPRmqiyt$c5})g zO`XZDMwM^0&+L3QRu1yhcA*A zo6`ns|4@_H?VzwD@jde!dr8OJUzcN)(B5Aa|K^s-uDJq;8EE?fd%856%ok4%-r|qr z)_rh|d^2%t+LwC2lX3shNdUVrfcvSEgQ}FxcCV!LD@a%Vvx@T&aW_XAP`317cL5E_ zFe*K=gr!WV9tyW?^qsWasJLJ5EL)7EKdxC?p_t%sd$RUa4P(}y)WrV%Po>F)i^c>H zp?PsN6+w6XE?D{2us%@-cO)`~Sksuf~z5n_v2mAqf~_qgLb;tjR4)w_kYpgDu@ z9T2p+SN?N}u3rXPpYSZEc|QZnZh6!dzRFxXxM5dY%Ls=&O{4Y<*D2BJY`7QYce`Bp zdyN&E&#ba{Q5Y$8s0An{6C>Z9FYyJ>jjyydjiul()aK>`MLs^~oP+JJI-v68`^`i_ zk2s0FmB%0;-KOYb_bi{FXsFIlDKMB#dD9azs!Cf`A0>00tzUkU% zJ!7a{eB`zLEje4hnyWo+$@B|}0j&`jzx(Vdphf!v{V|EOBF3iF$IM)je_t;}yntux7;06^$i3@Aq}N^`jFQn#|I^ zpLS(73aDS5ldipO(BxNd;@)qp^~?rz0$k|j9p|C@XXhN?%=Jtm209kxkWjY?7BVH- zOdw~7^KooQd|xKc!{6^=Kc#bJ&8T?{{9|0c-OF$)r1hZkk>ym1t$9h-Sh=iq!C?7d zh|J$R_$Q-5auLu#7t^u+o$`UuY(&p@keh$wewJ%iO%dM)r)E@CE76;( z>1%>cCK|6s^SK15N})3%xyAq1uG}RS9D83@{dtUYr)@U-MQWz0laH(IB46x*6lG$g zXQ0Gh8Kb53&x?%XzWHLQ>puIV*d>fizhbz2ICl6-_5V)medOD2-6}H#7<@f0_B*{F zH8f6;4*k>Ad7Cxw;r&+rpSUue1j<@0(I%r-xw= z;{N@Jul%3Otv&#iO_Gz9j@95R#`n3DfoQ+B;dtQCeq@DezC|x7DUEe?`pCb+P9yrw zFBV96!PRq$@~UiraMEsS)}rI4<5pw8n4uqb?ICu9G}*7VlKA(@XtH}(RvGz0cQm;_ zsu`$0amHo!D~=bHCVW)X>p@R_u>SWw2SgJB@G*)|tWsp9-;kBa%WLJQndwqi0$L^E zsJF2=WMNt9`QC5Z6Elm8*KXp_;pJk*1@$ASO=o^uvsZucoKWcQoPLa_h zuoIV926Pf8@4jCApas=)Gi>lKQlMr0zFAm3DBqJV5GGYVZab;GQDw7SdUf@Gw)m&c zneeLTbcz1U-b#0LgGD{~P7(k~)A8`e{V{E*(l767%+Qs%_*Cic+{4xa6-i7r6DR&J15v6h_(2T32uN_7Q9MFapCAvr@WmM`e`mmS%GN zn`9_oTfezRcKLs%_XqQHwL1oT2ObsX$Nt&(MB4f3%M9)-1CK}93m0H#*!6?|=W^cA zlVDlC##1%JZ}-O>^1jRdThx|#t!j+A>AClMdJC^$Ew9Jtm3=p?2zu4xT?g)-_;yI{ zucao$?2=~s-#bHE0Z?RoeutuAjbr_uk%4<~;+;ylu}^e-Kx*KuMoHMGl0@+@xCEpj zzX{Y+Lu!F^!58Gh;w^wlf?J{7Urp%*H6D~VWYboUmT5D8Y+b7}4FGV7@@QAw%cBL- z97@YvrviyTZ2DE^fGbHosddI?uSDhC_lUp^e^dLK;U^f--u(zqQvT~Tf51@{N%G^ z3dNT6p6~PCR(~-*i%(hYYT~W}4N#uasGn0!@1vx7)sFiH_tMN@iQG@+I>`}IJODFN zS@ocgFyyU@h)m)mr)mj2uB<{}{w>(YG)K>2U1;w9y4R*@59Y8)Fz#Q-5ITs7*LX( zKYZX*Sk;cz2lmqRVTmR05xZX_yGSA4ag=Sf_imNPudhwy7F=&QF)zpjM;eu?NZq+3 zhI#bvXM@CKwf)y(Mtty7d0h_JR0Aq!8#;cpOw+dvvn=qrz|cV`fex!Ncf@ zW9AwgqG&0vP3+8Z&`{qfK>bDs{?f7=P&U?Ye^9~)Zx&Kklz z@#@EBK1v0*15}FMxyalr2VmIslO^&K$UajuszyGm&3UXvW(bQmW-T79x2e=v;l zyg7Tm#qY!4viZVC-XpSdNGFVbq4H2forjP<4&F~L_wPNc^wzD#4GAn&9MpB^CSLv) z-GcK5gzTLWg#MHc`sb!{iURsuDN7GIhh3aI{4pf`5)G9G|5#==ib*%~p~_DK?Y z&cnkEB}1G0^+zn78WyW6rlzY<0G0Qk4~+zmx$FdO;e<@4MKBR@PE^X7E-mU;% zgr7SRw;V`?{CfZX1tt6Em;`?C_4NC)cmd5v70zKKd+QJFCW8gsenEGzjxhi$ds^kl z^+(uy_JI$mI+Rr6fR+~7*Wl^ht;J?BY2#(u+D(3oQneeIX#hHU|5@61L1z68mIP1% zy!O=TK}xFrAUQ`54{q9&T!R~mjV0xTqjG{v6RdaYbE|IjeF%_lCDwG_v87*5qvhvA zU#PZnNqKC_*BstpGZ`#*@bcHOrksPIM1g=E4y~3P`j1x#xJ@{O+KFm~`>|;UOEABw zyIEF!zl@QQtWo5|EFpz9TczLdwt&WHx}wHZcV}-xly-umvr)XsNKu&1K!1hAPuUjO zL|I10Gs@y=XU~@Ze(8<;OvXaWwZ|$mQQH6#j^)RTeir)nbB^)-%zdMJR{X(%<|A*k zR=u2LXc^G3BE`L3a_C?T2c&TvA~!N_{7ahwN`IgfyfYNsl(0q})4J^Xoqx{sy$V9b z&rK{9Kk~{vqOdTA!CwK665mKv;MdrmHuA$g|C};X7^1MudB>OF%Vg~kHfhxo{{vql zqSggEF1;^#&d%&Ulg4}Fswo@msumK`hSmIh5jR7I2=~11y>5^OvXD7In6lIbP$PXa zL@%v>1}w_@qMIJ@>o4f@M4gp`o5|M++Q2orbc=R|h5yGMT{XUly^@g;eXFmY> zXhfU4#%D7&kGgeuHS}#Y>iQS^#4Igtk#C&dL<^|IG|wPwPu{GC!*^7axna{qkfOGs zoW}OiyHJJalO#HY1~P^EB|24Ym7}ykVK`RkE77?-4Y+wXgE7UAr=*~7JE?OT9Hdx6 zGpbCoNJkJ%xWYHXQ3erK*4t>K4)A=cTgWm~+NjjGhJOo)6}WDw4PE(Xdv7=sL|}X; zB_Nr_=tCmWa$d1#i^;hmv-e>W#J{eT7;<>5OZNDnw?OEf-SafPO2JMLjL&oDq&PzxW!Q6`k7-CyMZ|Ia0lg zTCOzb-EO&b5s;**=Y2}5(V0E*PO%T0+O!p_>8=)Av=?o}V~2L8!;s=8z9HSh_Xz*8 zPxqDxqidXEbepjp$+@2>?Bbo?GWfr$K&8XRBp9p5Wf6A8toQr&tD54~HoFTtgC^jeo5WHNc3X?i_YMy)<{uKVkDsUYmQoJvQvUaoXK!s$Uj=+(S zNoXE+mc_@9sbrgW%U5u+$!NK*zVrv3snynOZ@&b>hFiMdGDn=oWtg(Kj_VCK{`LA@ z)o-h*jkhqOk5&W6Eoa#g9k{h{bBG<+g~-~ZE&BZJhEmWQDf|8;-Q7SO-N;fIuDW^2 z>0$hc6<~oe!nR*sfk;IRYLN<$PI-s@K6^>!@Kqe}-CI=Axbu4u%k?_V?;cu(Svdvg zxeUB=9L%BjXgT05KVflmIbPT{ssTUqK2xaL*U-b(O}9uN>^%(wt2eHb73e|DY`>=b zYcijx16f>MVb!}iwDUN9K=5-(v}rwQ3!*|>wFAOnZHiE$_(6GZAJ_nhvDq-K5kZ5^ zyo-`aqlsU22IS}_ll#6O)@aXe{96$MUF4-6nvx7{rMR>BK0d-%8%PFfYF%x=xogy- zjEhLDFpxTSG?z|*lnX;Py_XM%4vsDk0fARpyUf2G52d{*C-MZm#v|xT?GDvH|HL#% zySqiR`QUR%`AA-7!Q$hFeRL3Bv*W9UX;qRL@DKazMJPqULZ{uyQxyf8LK*k*-`t8Z+gxi-Jdus+>vF`-~HU~)z1%|hD4q9a!~Gr*_ZGz zj^&R2s=k!xGjz=k95XM=7-J+P(M9Vs^s*y%@KlXj#O?z|!m;UT`%ct`E_ZjUwObrJ zqDiaxFs2(VGXyOzyRdzJw8ye@FR^nLz5BdxUJRd`1rVej1j|*wtV~fZMW>E)sb=$M zKl83RZD{n%lHA_qkz_=yHhPBZOc&;MIPz~1^yR1b+)#XmB4`oQwinq;**RCTdu79y zHX&{ONn2QHxD)BiUyiWC>=?7zq5d!Zbs^kG$bwYk?g{?1C?LB^<&zf3PHb>njDcp~ zbkNzcJ-x)RF%+}6{=BwaKVeaW5Gknyf9pJa_w4kgFI+~t82mPIXopQ_OenC=e1AUw zQ6&4-vT|$M>ODZmRvz7a5bqQp8Kf3J876W$v-6jOPQHm;or#yJCc*bNt1;w^tAzhp z&Sz$XbcBSr1e|h<8Bc?O>ckssj#e9zZ6d&T8_=p>bzKyO)tj8!_M#o0XQ^uDSKr)w zUzQ$I!%G9C=;HSn$&y*{sP&Fp?T)N|*4KUH)YqpAf8X^_2MU3lUi^8dSF2h|&@$Ff zZNvM}d%B`|*1sFyOY1ptuU26*%8Z&gK0l z25l*k?kY{Kc!6P*ORlucW2p~Ey#b0!WU zH%=HqoI0-armtHOO5EVkAei#lj?o-WpmaE{ksH6n9i}iU=9Y7M2vtPUI>w0cs$lqa zC{44}{(k9IfwNlHNim-!HXj9LOjnNOLo_e2m5Q`DIe+J_%u8l9hMEaJ$Ise#zt4DY zj1=-MHhb&!t&R0L4wi>N#}JZ@;E<_yy2XJ;ZSF6Ji`-gSMsXGqL<^*Vq>E$t+~EN! zl(eyIL@>U8*0g0(bDI1DYK=kg!LqclM3HtD$|-*m$k;+x??3 zeBzj~TY+KW!6TA}li9abeSGayZ4-V6qr-=*bkRoDuO=SY|4J))5d7}EH+lw)z%)zt z;0>MbyoYt4xI3Z>&X=rTZ%%Y0pF2P)CNKLm%~QzKXLWdeJ`b))Dj6s1R+gpRUpq`Z zwH>QBfmapvOs?9~f?LguQr zX?gU*VCH2a4#i%Lxj+q~ydtOV5Gst$;6IFcql9h384ZWCfr`^Ec z*~A$VmvhyVVx_!uoi2MjsW3_&dL~i2WD7r`p+=D>$d#jhK1NxGzs3bgSf;I_b{@}j zjf&d(?H)Q%UBmSCZISyo31b&?6|A6kHB&!9GdHvH{Hta|D`&urw1kjNS>4pLy&o7L zFkO&E#ynm(p-4SOyO67k<7|3E-9^@pLSk~Wf2p5Ze>(Er@L6@3Dby8P7@x|;=+6b= zHu_OC3v#Jfmxv^$501Il_mde_AYhwVO>Q@5`bxc_rtTEmFX{IBG+1$X1$XuxdV{k` z^=E(hET=^Tp3Oh&CrJSGdiP*#U{|{B#&_Vul^G14oUwxv5RxvMycveD~V;^q{i zs=_DB$RU!4oWMv3V7>_7Ya7?+eP#LK+`N^qy^nIWnS^y27=4J+Ubm7HGzmbl?*l#5 z6}F++p5$VY`vDTss6sqaEdhV}4SDkUivLg7K;_=X-$M_vzxr$^OWYMxz4eYo60SJJ z#QYr^w#@p#dG759BA|Y#9tDpJ@<(f02<;?4-jf^-Z1nQ9@|(G(o$T2%)jD4ecH@)k z-}C9WRLB+5-_lO~l?0i*4-QvO-f~l>d9YUWb37?<6=nLywd<1KhIm~2j1vT$gFd_1 z8KNXr5zlu{-vm%dnboAopR5qMJ5;rI#x*h0!=P#i5E2@Tzr`a&A3#oYU7gD0^ttN% zu1@s|s&J9Jhn%he&#=T3o|dJ%S#^9RIeWS0#CD@{-m1W@-n}w*H~P=<*eDS!29c{E zK;HhgrL!_l4Y0%nDW)BY(g;pEz6g1wQ%_LrVD0M17DVUGV_qCfg^e#%R6K{M45MxI zX8l9=nifqnCl>9WCLG@4#BgFPNRO{i9U43x$77n_HOawmE}uUg6T+JC2$Qt*S2eaF zA68Y$X1%N?RTFkAW%2Pl{tMNUHEt{6`srTJVU|U1*0V1gRSC_!P8VHwbWyqDj1W$_ z)ml2E6DvV;-=%7rtcUb`I)wld1<85Upc0*Cue%Iz%#b<~1cu+K7voK$e-p305h?rM=HF7{n0gCYE7 z2H%ats)PIf+`jyu(*=f6b+?BKyLr*g${R1#2A~YjbkC>N11+pC!hII)Df(30b_RWN zFL_L5&eQ00elZKxY@*eF*yYhSZ_QZigPvh9?(?)Y3?k@n*f$e9-LD^F?r+j{F9}Od z$;#9uZ{vu-nnNND5VLo~x0(ii+SZnMsCL`enr?jSFb^NRyb4UBIXkHh&FA~?_xA49 zv=8}^!xN;u(R;Cs17ja;&8KOnB_KKolEH|IV>@4F?>nN0QZL+f@9<#MzR6K0ic6x) z7Kbp6-ggR`TVqeUDLnFG_>MK^H%)8$->rX1d{+*s7yb}g;3y*l-w5SApWC<0QvTdk z>&nwWt0|>Ua{4v=+DU<3g1Y74$~M_!amgdh)CZXJBA8rl%BjiwYXz}Tu{7CHnqTMk zdI%L-Xe$KoQ6up|mVpnav@|)}^Bd6``+Z)N<;b79R(2`Xe2U+q)@wb5)ZPrM!+hk+ZU>Y@CKvyz?A1YS?E_VW&Hjbo?p9Scijc zXdYe;dAlqu2+9XLUQXCbWoSYt4#kqoMcVEx=c=8uZKF>Qh9Mev()(CI?W~~woF=U- zSM=tl`3Nv(2AGqrK8Mc?bat21ZyYD1%~jDjQIo5ub@d@TTFG#ViVYm`QiqF*uNtK4 z@DX@rA8}I6CNV%tHRX?v!qN|enLnS}cuG*$=0?_=u|&*pZ@@(2!}nPt5})!Nnpva{ z-MOLSc(ge3)h_G$lZftwvsdu;LO>>gHk-O)x&%t6XZTcl1OJx2^LoAo(Pqb+?AW~a(VM;i7JI?PSRsNTd2HtJ{ZDfD z?qLZDpMy?LqQOn(Yv{4yLa}qxA#*v?VqZDMW;9gY9lorPH%)E36^q}fpWk868An63 z@;Uv*o(H>5%ZO8O6+Od}G@$mV)|W`|F+09XIcU%%zVCBzkHoDky?rDrN}-P#w)tpT zYOW5jh*EDNOwAiLnaWCFRZ3x{l`o9Y?NiyT!jnwZnt;rSv@-W6(J92CGrm6C=nP!aKS9S=R9iQnV8ZMRqlF+db2uA6;_*0 zWOyvT5j|bRn9YdaK*l02hGkdln=wi5_b+ZNQwz?naI7l~|9x7dM9gh_=wYJ^ytY6=BOQ+lS%5Ku!=Q zwbXcPoqSYmIv+&;lR>RyAM*85P+eu0Y?sGJ7yP4mP{cF`_Se`pET?sBih~!M<)D=w zF0A1I80#vY`TOTCA%K$kf!|2S5MAR*fW6mCCx&_BE4KDXZO0I)A&S9xYi6pj+&GzE z$eUElPVt0zGbMPj!#h945`OkC!##wDfXy%S#?5+_cdTZr*XJvv#J|IkgvYjCW_ zWAxSed`B|^E^|R(TTbqiJgNR$QlulXp&9~KTkoqj$Q1JUo9Hy(eEo8?M5ev-SSg(S z!Hb0@*LgScT+D-@vi8x_@V>+~130C~Ly=aB)dE^Jk=eL}zJ_O*WX83qOD*A#u(FPF z$?n_3nKv((Ial&(r<01PY_D!5iKzTNP06dDsQ2Z1dQWY-0%kArOQkn`5w2$F)5_vX zkI-z?Z;ygLWzf!|q0_k?a4D1D)7Fiw0o`+Hn9sPxI8`>uZL(84H&+cM#@dMPWB*WE zso!6^9$w#Hcwjwv6-~LIe?E)3OYNQ*6ZzBRn&Ny@{K@IOZBe*>fsIF?185Hc&2}km z$QwA>Uz_#V5zp%bZIPs!e7z$=v7XirR#Lz&2O-xp$~LrF;7XD=R=cw}b|R+NG(cZ^ zTUVqOyn%b$G}xEk?hT!cwFMetaNZjO`hL}(XaFbaA1mf=7yE^SX2~e^{kc~Z{3wm| zYQpR`YJC*zNsp>07&Xb@9-FSP`TDVfunebE=fbm@YL{sgGfZ8d$hXys z_3ik2J;4NMm`<;eKk0V)QHrg!(8j4aMY*;y`bCjVSr;+M#T8ftkW100L_9u#Av-D$ z(VuI`ncgNf$0r6h25uhT+Ni9<_|!l!eal}DywJfv$&d3l<$DJU-8LFwNjY;)X_vll zVo1ggIY<5sg-gn58BvNEvgQXdGJ7*}tHB?5?`AmH_3aJze`h9Wtc{G#`_47y#h1rz z6XXyA!r^(*rOo`_wg-nBX=$~4OR)(NNk1)uws& zym9TWl=k4sJ~%P`JMK4tFguRc?&UZffw(32o-{vM?PlC5n|))oG0^1jb)R@NyyZ9~ zW~lbzt<&kE9km!Xje=z5W^nh&v0*RJ^SP`;lHnv7fpBy#TM4H`P>xr5@NoNnG?z6U zZRqAnr;I%DPxyGV<$#BR+yQwbDk#%~;S7D+5D_7lwbmDjj}BI~ z?3w*3mm2XQ65cEHW8F{gEi{!lenN2okm^|%Icq&W6qG~P606lU+*U#n_!domSL>ri z|F(!C{;^y06U(qWw~)M<+1JkiM{IHnUT$E0E0pH2Ub05@%;^JeoPS_ zHrT$>EU>Cqsfl9k^r!fGxptWnBUyfe3>4hjzXr-B@*xt|KCoPrf8PK7=7d10(|lV~ zo5Y4lKfeArC^>hws4Qkb5QuZn9^yds<$aTeK)4?>1gQ$Y+k5I+t2XsuvBZ-dO*Y;d zM$6ijeA@0gYHT35SF@h`TEToioJ-A5bq5QDav+>A;_i==mveNUqm^l0$Mhr0mpP9h<_#vcZ$|M_Mi zb5wZJxgLkZ3{}d8nzZ!$Rk+u!6bFXhDXI8W-^~?r=iC`P-~OnskS6frODODcgp@sJ zG#*@>Idj+q>1XCsg@Q2V^9RVro%gYqnzgVb)A-+Z>vP&=>VeJ02eg?9OmklHCO=Z6 z@<#D-vD?)97#2{(h25R`Z;)aS&fxey=yd2!%#0v!4UWxr)>2>5rRtR~SuenOpRMtk zLjY$CTdqu5Bi+T>ykYi!kZEa_*MS>pq|_ z)zCLcJ#r+RJ;(CY6Zhn(3(jK#X}2Jj9N|sbw67`yKufxwY4n)pFm>K9BT3LNw<5@D`=oMaL0z{~HAaiyg+_Yw zy-Q|q-w9@-t=@;^VN|}WVVZX06Qa7!n?6L$`26OWBOT7%bcm%i^~f42Q0PZMVcdTf zrcSVz^05rv45n#KzSNlBhenkZpF;YvQ_KTC+V(x#1gdFXd53KLpHJO9 z+I8=Qvhp=fU~V2uin{GgGD)y~g9(H$L~Zu&5r|AbfT+)G>){M4c~ zIO$oIsqKbHkcbDd86*<2^pX%04` zo`;l9qG9glhtS3wKV}!RUhdRhJJM5&_wRs4!e>k^YAu)cJfPlOO6fGVynp9yw-e+kjkuqA-1uaT+9t9OyP8Y5 zvaV@4*+qO6t(R-Tcy3p+S|T&EH0RS55A;9oFQG_NnJyS4W#FT%;XJ+@BoS~C?1ptR zC12U|!^>>yNzv-HYh$iBAygg)H=?;gS4T`N3F>$$aG|&>V(y9F%2`#GnWfvJ z7kw>V3w}k0oV^_|>#rco_)(&1nzk6?#x}q;dpea_LmxWkdEgbC;~-*MVzXyuy7Rjk z9^F^FpY~+@xE<_}tS-vqkzkjGG`DkHRXpEGx&W}e!eU{ldgG4`l{})du7|ZjYE)*Z z=eFp#nbxKrqpZWS`t-?0)%%(zBgJCNQpc{ZVW@nSAv>&8LEnqGIC?tZ6f;|dqVja~ z2AW=7>!G`V)1))h`(F`SY-0UO!5+h~ZXndyzUx@3_E~ zWsGR=WUcJzhh(pi7tHP1mj3(gPqH9BuA3=Rp2rQbs|uyI&UuE~qU~qMP}<@e9d+ub zN-nXJc-qnw$NF7zitW;b?Xz$Yh8D4=NJ<2WWSC9~ub&vJt9Yex7wix^Hmpv%PT1mk zM!s;pKDxGd;I2MBx0GI6MiQ%^y+SavII#WGXVqYZ^Iq3ZU&bm^Sl+B%X^eN-O|L_@ z9wL(i6fElOrfL#cG}9O*g`p0{&6F>Jy}k-6INiQ@%p{d?Fhc6}a&<9^T`0=k&I(IX zOar1l`QiA1DVk`fMotX1Cb`+Ht2TGN9dnpFByuxM@bDi{Di_*U^C<&iZ)krOm*gVUEP`M*|L7|IE2U)1#hW|Ou*i4 z_8jKE1}W@8O%q{ce#Jz|Ugc3c$DwT;D@DFt0H5ak*xo5_*ps{_{18*OH8c&04~=Ts z%qG+~<>sw|8NF+Airy zZKuk%W^5rwbT(XA+T!Zd8*2?$3F!3(YY%)iy=L=+6t(lzRHhhr6Gl)UJc!dB50B^k zw!rNTLjaTo>naZ5~D4w(KR zQt(yJcXJ{glUG_8`{ld~Y1FkF?HhU~k&=h(pYcW&eaH*D5#O4gVp9u`gglI&f7ROc z_z|ZcxlF^ULHXMY*-)d$al zJ&CLzT}ye_BxLS#c7Lu4#!FxJ;R_+#2%t#y$BxFUlbf32$(_5&uTFVJY7;|vt>umT zMPKzJr)HpudqWlR1jD(In7*ckQdd|jH1GIJzNN$ZdcH+-{?ZIgZ|V5T?GEgOC9@Nw z2(8!qPO+%&I;W|xcF?L8<)R!yy$HJQlXx&^_4dHSB;NCb6(v?pl@NuT{HVXV1Q8 zv?y0X>~U*@$F1}IE^ZNd=rEq280N7n?iBy7-irk>0IZP6FWDyP*0vv)6f@(;Wo#5Y z^w0<&*AQZ4-KV5S0dBwup8t)dInN9tlD)4SNxz)+L5B<>?l zh4y$Q$mQpwE5X!@vE`5Ee{93NH$go z`|b7Q0L&fwqzhTkNUFSv!F(6=>Kf@v+MQ|AD_Kz_H+8c&0{m?AlvU0OffueQogNJp zJnoJXDr3p%9K0{d$Msmm`I|_Eu(gZ51<4Z55l7eqmh8DW7tRa~pMI$YCG_#9y%6#I zH5tL`;12-Tzk0Mi{_N~w6-txslF=kyLHQg^-E(S&4t@4>%Da|jH=1BGqLQ?}Bx;0w ziL_(*WXUkNdEZ`O-oW8?fbigW9dBQqX_Nq>d2?mmseQ{d+oqD&<@qP?rlY>R!iJ{W zy8bUx-dmVgpz96GK_f*hy65a@m(E;wA?$F_T6T!5o6ku1+bcvQnVT5qlRpL7@yczw zYWNXB(`bAe_96uCwxAOleIcD=- zW@s*FGkhWbRLzTQHgq=o;vV&(qOarfI*l{Kt$^y#E|_}8H^R2I{?(d2fm2^WKqN!s zvQ~gsRpA%*H?qrNE#!jnpq#2!DmX#;y!}(gx^?)@@j`lJFTLrX+>#(`KwReSJ#R}= zDbIf1VM9FB$5O7a(dp;!&hu#X(iMpA+62aRG|y3@0Qvdd6Cw^l;{ac6mkl{+P9+~6 z{M@dFkQ<&To=zkmwaLYA#UBv6N-%p*{-P5#NB|UisGD4;l*b^+e3ee*jtXzWikb8J zh+RmIc7JQUV#$bl7zx^u2?CbOP z!SaLsC?GQm%pIo&RSSC^jPjJr4kOUtop$%iRWWoR+V;czjR`Z!`ds!>&_mv#CyZO} z6YJyI+_+T#8E%a*z6tI|uYRyrnpC;XqakR1>^UF!jE?|Y^s2)j+z(l-Q%jgVem(!z zN$TK-j|1nNaX0k&t4~%ETUB;LqtK%gH8m`gC)7pfneRt;hrX4GT)qbn`R>7OR-<%8 z4?_X!al+jDhUfgy7#kAcAbt(!;UT%P)`IoAn_~s5nGj`}xmqglquv?o0zr?sb2=R%HCIxo&PMZrVd zWM9!k9iv zK7vn}y9LO3WRLHNRrd9P*{KG9bA5SitBgK;n2;2AFzsZyIf56o%(1`9h zz`H*43BX*ao@ew&I=n{3G+^$Tq4*{3OOC2VLTH>nqYA#fYjA4cv{lPorcug36EuJH z8xEHv>;BOr&VSN8@)gpJ7S?J)7r71XVPs*>zv4EtNAtkWK4NFA@wnSRMAQ5}u^;#zDBl743!lc|b<~SM zaNxMeKZ^>7rc+NUepzr-*3-z)z0;5Hw7-RCccAH}ySzhFJDDTA4a;K)Sj@}*YYnjVQ@6Kt%j(|%;A1^ZI%|G<^Sh7y{6$usTZ=L!% z)N-7%M(h94kE`^wvjZVF2C{tty3C7lj{F1 zL-cBc5|7BIX`aBAH>U6$zJnfPVgi(b=U^-&OTwieGY9B*Bza@oTv*{%4>yDR(iyhI zOSSF_5UB_6>eJ&18kj++g6|sAW6dIQ3kR``Vv_{gWGhYd>6bLr$oHk_%RqJkWPD#* zdI(&8>ljkWF=C+tPQ^Bm$ZcW8gtYx+N;gnkUZwbPY!Nrp$?TDeI?fH|(;nUgb#Uju zLdIa`ZN9g4G%5ebsTn@+UQ-14_|ZICSGhZ0givcwMiu4(e|r-IdQkZGM#v9UDpM4vz>sbwaA7hu=fEcuDSetoZ&Y4VNY*g#AWzZojeL zDboMqS9`#=j%eRD-t?*E($HZe42r~v@iv`&wcU>`(5wfdFd+u>D8DPD^igR(+t@1Ht5){9bcz>4OPuSOn~X_rE+5K7%gbXqsxLo(|CKvDf%TyoAp(>l5`8?hm(2BQyWf zk^I{xf%N~{yYhFayZ2v1QA$Zt)}oTV#Wwa9gvOL5B1_4ZExW;tq*BTD2!%n!L^0XN zPGLkwvKx$jF!sTi8O--R>iK-0=ej=s!1ub$PxHfj=6%k6?$>?p^SWQ>ocs8_`9mt6 z8AzdFxWZZ`H|*g?>Q>bitHUQ3Swh5@ z0{Rnu!=(-s8K>}7Q$32>(u~v*ji7f62}9IJHs)?G zpCK?_w0I3{=Tm6aN?w}#vn#&a*)5o3{1%U_mQ_W8_K<&DVex+25N(*lX)&RJ%^65~ zI5Tm&cCG~rVW8AQCsKUZ=fK(4j6Sw7gn#{^k^vrMlWOMGe6SMY5#}H(-?fv$ZD2OG zp_evw4IOQmM^Zhh;_Q|8E8Vy{(12n?VnTI%aAPA~I13;E-)QeA?s_D2L6sOpPIyvh zNknS+ymQ#lz^hh~ z4dMJ(ddGScdzzpC)*$=)Qsk+d_8=H8bOZZ&b<80UX*M~aRv;Iyd4uz9IY2gnYRQ>k zTdR@EA+V=aTZ;_W(Ro#hYBroG!=>%la)~1~0dm)7dEJAnR*(;NosZs?5EB+3Z!7XK z33ay#Un6u;3Gz-Z|E0B4r~-qegqK_j1}ggKrrkryY}XW?Dgvh^`AB_n*~nAl_E|e; zxPY_BL&|8lGCHd*$tj<0Mn8yWiVJB+3R#@dN2FO)x3y9#E}@9l^pma?j6SE0dfm*{ zRtuL&$x;JmXlaEE&tuYJB`Q9u>ddxM)DsY!WQe4s0v_&Ot(f+xR?&13IH&Q*A~N?u z13%d`ayj;4xcX`L?!@#$f4zaX$fNkd=+;+bK1Mvpxeo@PCQgffU;YxWs?*wkfzQ9H zH=gCI5X=@5m}#I!G(hU1YX*S~<8HQ5 zR8#&ByECAOGtsD9$2IQz^C}ri(`h8#i}A8ioa_-Le0A=}pq48zaRxTs0CFjjX&TCH4$|JBPPHyNPKQ9oDgx?qr!fysoCHIw`nXS;&d zphOF^f=M;m2mPK4bPoBDd^$ATnl0;^jS%ar2mLgVoa+ry)spM9iqtG5=N8FtH=*)d zYE{ix{uQR_AU96Z_IU*fxLSQE@Rh|o)UAx>qD)mz(y^D&`UyjC>8DIk%oXKOIYl-* z0?lU{AzxMBN>NMTR2_Nsh6u^ieHA%6;xPb|2X|j!O~hy(u!MZlvQpVP^yBids_amD zOs4t%@iccJ?KHwRAHfV@-Q`4gZ%Ss{AprKWXo9RT=LureN#jJDD zEEsN`sK{v^ZLOca*AmY^eBcCjtN-$==q$H(#JN3wSnQg>cqv_!4L z8M0oFDHD}KWk-iy_eb<-gIe#}^sEn7UdYS8>EbzIcTBg`ay?AW8a+ur^HBg}>E6S= zziLijS~)m?x~7pZsfwWHM*Uh_n)`l7XCLnqX>WyRzoRw_M}1Rip~$-50~6F{8{rVt z!nIP*q@fz0$`V7J&Zdss*ZQR!wjs4ia4(TGENV#jF6xLY-^lX@((E5WhH4%eyf zS%E_uRTx-ip|NYfNH}q*$*H&)%^-t`G*=;r$`a{9rYdZ zMMi1P*I%&J+ile$A=giTM;s4OqwPauFKQf|j%x}Pp=;kfc^$Z?df2bsuk4E>{#YW~ zL42`dDwSw4_!^xJ@pq}mS*d8?2}p)=G;VkP2t)9(rHJ<4;UQ$+)I{s@<#=qzd(>v(&u7y4O+nKcZO=bAb=WgXr7sh%yxzY} zmzC|m#&$}>Q8en&C%K85CtLoZaT3y(Y>DVCZ>Q#tLq~OCtR6zTQYJFzUB=%Q;YWBs zI9S2Z%TIU~T6YuFZ2h+OzrFTYvz2x7<+t0d;3svTZXDVxdjfq>KKGqNtg=MZIk&DX z2x|yF9`r32UqWB=VGXk=cH>mj>&jjnWxR?o+l9U2!nVkN{)R(@=h_OK5L^>gX;NgKz>W?jMVKzqM7y_^M6~=(YUl?JKjTujmpuj8L3H z#*hm${jq@)&h&&lrwt)7iu;9535EIBElcu%r%VZ<{( z#f#p=BqDPpqje?`N2pa(rBxbg7`j^}u5NI{^q#06%Vm+DU-zW9w9hn8dCgh~hN-r> z3%w}BNl;t#TScqLBeg}7IBYlVet&2AUY_5ou122Yyo!KAmpJ?}C(&b+;I6CUQmvg> z=oPj+Hh!JvT*xGvC(0+vj0;;N;>j@4YGS&#yZ^)NS69br8 z<*&2O(h0QcMT1I4XtA~$ouC5m`mJ(1E!hvN9LbmCJrMAY4X08$*QoBJ1MWPq- zjzv^@k57!+yJoL%TKdvRR{3v$`jk3|iq@p^-`>nvSiQ&UsNPJ90j8vHO+s16*nvSR zKH|s?e;KT0eZO?3yI`$Z|3ixbURFAPF{n$eXtvpuI5Ns3Z}qcObQWhNrnYB)PX5cD5C8TXQWM`LaX&!Tuo-FyFD^u)#m~)Cw`(QYkWkVardWWTwbaz zsX(f4AN4)Xz)iF*WXG#+-YJb{h!U3-Ba{Cd(C(o6kY8qIpyY zBW=rx)t4;X?e$2J2o;6Cl47Qt>U%~@a=YZ)9nPta`f1xLm$)YwD6cq7msYsPELT|@ zK>!&A1U?$##w`ZPn?CcJcO_RGImVnk(*{@ru&ug6c7_v<1fV1w!z@%~w|yle7Wne2 z$l2b*`%Ht}Ewp5BTOjgR0L_9)QH7RU&1xMZE7YJoM}bVSFGaMM;U(g!S%xB-Y1IAW z^k~)nn4`NQ`71UOFb!iA4Yn8La;~o-C%Hbdg=F7y5Xx3LNrO-e71(^(#(Q9Jnx~5t zu*zYVE79cKm~`4en8ZH+T>R_E^jGdRNQ-P=#n~{$nG09LBJ@-QybS`z1~;s|B7U`i zZHBV6!+@irm_UA$TI=9J_H+uu2;Mg7U@qMh`bclQN0w@CFcqV(#`MBe8Rn5Ec=chC zzy$=`ivO{#wFU3?)kEFDIsBEuDq3f#;zNKFv|5~m&b-s%p4@O@McP;O%jDn7pN6$# zrY%ly1)Hf_$zD_=a@7M55_p?LACXO6)uXCgiTMJDYb?OENj(lfI*`OKiATT<3&vJJ zL_K`YG$eZ|xEImRcATGKEoeBXUA@>C=2T?~y}`CnY^NN+5R`!D>HCoK+LoEJW?%Jn zw0JN_@m{CQ^TJvEdYxeAP@luxBzdDOnYLwDU~^_83Z%XZ9lcao zB!p(uO5y5l4VL5ievtL$t`Pe(=XHW`sRF4>*q)V~Nm7>@(zCY>Xx8Uc^I{3Y%_(G5 zD0IWw4UJ~D-@H_Q>!4p zz1jkyj)3UG!CHLaa$f={0{7kHy}$MY#T;o0AWG*3O1D9&9iq!uVtC)C@V-iF+%w8Z zrjD(?PEK7J#YX|H|EMX{uP)^&)F^+NCT%^r%04~`%owR$!^r%VM@5=3KulNyh1D|> zh+$VEY~R6Z+or=9BCeI)1$NKCh|v$f6vbq#mPQgwoNunU)9UL8;m3N8=VawR5@34W z;g1d>YwaieC2{%J;7Et1A@T>sg=`AO6E0uVy=w4Q2`or2O)`>JK69!6yt=X&y*}K+ z-sKBW$JS)wD0)S1sCSbjw%5>6gofz;srJ-{5(lOg@K? zX=nRhH!uQIdxE(Hs-UjEUKN;XwIk;N=&-{QW7fyxdR&`F_UC@-ZfkMmVg=F= zVW4k|VlwzC;huJ`LQmC{Xp$PQNdAG{&Ri={TQ|27ND%DN1(Pk}12z4AJ#MBFG3 zWPPPiS_-zH_#<^r5R-d|$<=b{N++97Ln3@%zf_f3?&;{!Z<*3~9odtyT*BUuGfU0? zf!D7aoNl=r=AO;^1lULsviE53$t`rXC@w)G+&QkK??JJ5Cjy_@W!e;V(6Lu;{L`)J z4;)*uTRzy>V(^k+`@*`3akNGfBAWF-wJ15Z5Dc~Yg8HuDTf#k_E4x)bj2-IFR?m^a zD}TFa32Pafp7L_LK_%%F-)Pf z-h&kJi)2WW15%POw&U-5$R#Jhw_6k@9y@Y=nS7G~^LDnHOcp*HPl2XXrzBiL6E~?W zVr{GHV7EoG>1~HfT+0&7n$?~hz&SLz3B&&Yvw4z9l^ShYa-u|AR%c-L2i$?1$4o?> zcRsR2)`)kG5H!g1>mJ=DPQk)~x*)V{$-9cni{#2^%;Ly{pS1KJ40W1a=hMJFFkj&^ zcuj*Q4T9}mxL1&;?rm=rXoaLXc(Yo)3OE0pCp)WH;S!J2@J%hxYixcQyldCFySmpd z8<)6{_65O&*XHa^KQ!XiX)ccCvy(a<0QV2Ej{4MYg%h}jA6MR8LUT6^66OSc5% zCGPt|#`ADdeoGTcJ+|#Ma~pKPiiu2Vfj*u!!O&m8e}?BKF^sJ;;tiTfemM7pU<&Z=q+0j-e9q<7YO5t~Kis2&3#kiCnSFfht8fVc;rKM&N(Y5+N{dt7|% zSz_O-nbU*96ypjl2n<2JqWiP-aO@T4KmV-X6QCnDJc)VkLz+=8_nm%YRYCqC2guE5 za+(XWiA-5#jS6)J*gUlHWyu_cO7|+z@4SfVme2m(raz4<DqMkBn*Dc^;@X$e{GiaxGc z2rb&EODs~iWlo^K!727!gO^&+1YElH<(ibU{0&M~RLymcRz*{DyFRV-oGj$>K$VK% zM$gI*$%U?lXRlx2zURfrzG^P0*xsP^XShOW$JWmpM5EqRNY7KcUr#()g@2{xX!Hl` z4?rivI;-wT3@GC$%@ zkmdkX@W?^Tr;-SuzM7jhwvCcI`>vseyD@3V9Isnd#8gQ&kHMFahX{gGN42i$CUMDo zgIfBb9Y7gw;Ebi5`Kx7msR#NH#+i!sUwlAN7&DQa8FKt#svfRE20X9K?*A4xm z3q<9z%aX)!S4=74-L?wPnC1QL_L|@xu?ruuD?VVIAK$tHs_{ckZUf#ft`E$G8SMR15P7-sFqHh zZB!H(1NDJ}xIHAr|%Uw#Iy`*juVFp{i2R7aKQz zbl?@cg~K=O#`i)G2J`k0Crmy44besra_k$e#$#n!KKEX1alAfn@?uMo{{rlxxmIx` zzv4Z^^2rjhU)Jbq#e;mx>E_tmx7H)3)@S+cta8(n2mj!6d%%E{#H&H;Gasyvot5&E z{{GqhD%0_`7%x74N_Z-Exr2Ri67`^ln>yFzqcC%JCdcE*O6~gP<2gSGF@O__)P)HkFdRK8GAsZ;5Oh;Fh+@LQMz-ETz zp@Jslqrr9t3qeZf2kG^nrg1t}2Iy7#hsijTpm9l$6xFH*XCB`Cus^2${f=gc=FifB zA+EXYq4ebG6$RDwXpcSI?CS&c*F`Peq-{aJxkvUhqbRpHw#TR&$i%$|_~L<(ojm04 zt{E^f2mMuuu1a#6n7@xRwi8MW?! zv%)MDKYL-Dah|H+W6VCS2s$#mhxE1 zh_M%(6#Gv*{nubId)8jox~CA=lI-Kdo|CL%HZNmhf@_mg{?+f_GibdJ z+Cq{4k-5yvy%{4XkTL&ybmnTUV+!r2$@YfJBGwQSk*UDIyHn|q_50Q@vIRIJ*J^s7 zqAz#+v#2~`fc`Efj;42(yY#9t$+I5-@`4(8zP{jb{r48sMK;;TG$M9=C`wqsd~-VdaFToVo%7wqR|8;APF857s=~4#dlV}$=12zSh~ zW0sxZu!Djf6zrg22L(GQ*g?S#3U*MigMu9t?4V!=1v@C%LBS3Rc2KZ`f*ln6ze7PS zqx32kv + + + + + + Countup + + + + +

Random Quote Generator

+
+
+ +

+
+
- +
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/app/internal/handler/web/templates/another.html b/app/internal/handler/web/templates/another.html new file mode 100644 index 0000000..94158a3 --- /dev/null +++ b/app/internal/handler/web/templates/another.html @@ -0,0 +1,15 @@ + + + + + + + Count Up! + + + +

Another {{ .Data.Name }}?

+
Back + + + \ No newline at end of file diff --git a/app/internal/handler/web/templates/index.html b/app/internal/handler/web/templates/index.html new file mode 100644 index 0000000..3c1b229 --- /dev/null +++ b/app/internal/handler/web/templates/index.html @@ -0,0 +1,19 @@ + + + + + + + Count Up! + + + +

Hello {{ .Data.Name }}

+
+ +
+

+ + + + \ No newline at end of file diff --git a/app/internal/healthz/checker.go b/app/internal/healthz/checker.go new file mode 100644 index 0000000..66de0e2 --- /dev/null +++ b/app/internal/healthz/checker.go @@ -0,0 +1,24 @@ +package healthz + +import ( + "time" + + "github.com/alexliesenfeld/health" +) + +type Target interface { + HealthChecks() []health.Check +} + +func NewChecker(checks ...health.Check) health.Checker { + opts := []health.CheckerOption{ + health.WithDisabledAutostart(), + } + + for _, check := range checks { + check.Timeout = time.Second + opts = append(opts, health.WithCheck(check)) + } + + return health.NewChecker(opts...) +} diff --git a/app/internal/healthz/grpc.go b/app/internal/healthz/grpc.go new file mode 100644 index 0000000..46232d6 --- /dev/null +++ b/app/internal/healthz/grpc.go @@ -0,0 +1,39 @@ +package healthz + +import ( + "context" + "fmt" + + "github.com/alexliesenfeld/health" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + healthpb "google.golang.org/grpc/health/grpc_health_v1" +) + +func GRPCCheck(name, target string) health.Check { + return health.Check{ + Name: fmt.Sprintf("grpc:%s", name), + Check: func(ctx context.Context) error { + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + } + + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create gRPC client: %w", err) + } + defer conn.Close() + + res, err := healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{}) + if err != nil { + return fmt.Errorf("send gRPC request: %w", err) + } + + if res.GetStatus() != healthpb.HealthCheckResponse_SERVING { + return fmt.Errorf("gRPC service reported as non-serving: %q", res.GetStatus().String()) + } + + return nil + }, + } +} diff --git a/app/internal/healthz/handler.go b/app/internal/healthz/handler.go new file mode 100644 index 0000000..23e4993 --- /dev/null +++ b/app/internal/healthz/handler.go @@ -0,0 +1,33 @@ +package healthz + +import ( + "context" + "net/http" + + "google.golang.org/grpc/codes" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" +) + +func NewHTTPHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + } +} + +type GRPCHandler struct { +} + +func NewGRPCHandler() healthpb.HealthServer { + return &GRPCHandler{} +} + +func (h *GRPCHandler) Check(ctx context.Context, req *healthpb.HealthCheckRequest) (*healthpb.HealthCheckResponse, error) { + return &healthpb.HealthCheckResponse{ + Status: healthpb.HealthCheckResponse_SERVING, + }, nil +} + +func (h *GRPCHandler) Watch(req *healthpb.HealthCheckRequest, server healthpb.Health_WatchServer) error { + return status.Errorf(codes.Unimplemented, "method Watch not implemented") +} diff --git a/app/internal/healthz/http.go b/app/internal/healthz/http.go new file mode 100644 index 0000000..33df5eb --- /dev/null +++ b/app/internal/healthz/http.go @@ -0,0 +1,34 @@ +package healthz + +import ( + "context" + "fmt" + "net/http" + + "github.com/alexliesenfeld/health" +) + +func HTTPCheck(name, url string) health.Check { + return health.Check{ + Name: fmt.Sprintf("http:%s", name), + Check: func(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create HTTP request: %w", err) + } + req.Header.Set("Connection", "close") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("send HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("HTTP service reported as non-healthy: %d", res.StatusCode) + } + + return nil + }, + } +} diff --git a/app/internal/instrument/otel.go b/app/internal/instrument/otel.go new file mode 100644 index 0000000..bbfcc89 --- /dev/null +++ b/app/internal/instrument/otel.go @@ -0,0 +1,123 @@ +package instrument + +import ( + "context" + "errors" + "fmt" + "time" + + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/trace" + "goa.design/clue/clue" + + "github.com/jace-ys/countup/internal/versioninfo" +) + +var OTel *OTelProvider + +type OTelProvider struct { + cfg *clue.Config + shutdownFuncs []func(context.Context) error +} + +func MustInitOTelProvider(ctx context.Context, name, version, otlpMetricsEndpoint, otlpTracesEndpoint string) { + if err := InitOTelProvider(ctx, name, version, otlpMetricsEndpoint, otlpTracesEndpoint); err != nil { + panic(err) + } +} + +func InitOTelProvider(ctx context.Context, name, version, otlpMetricsEndpoint, otlpTracesEndpoint string) error { + var shutdownFuncs []func(context.Context) error + + metrics, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithEndpoint(otlpMetricsEndpoint), + otlpmetricgrpc.WithInsecure(), + ) + if err != nil { + return fmt.Errorf("init metrics exporter: %w", err) + } + shutdownFuncs = append(shutdownFuncs, metrics.Shutdown) + + traces, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(otlpTracesEndpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return fmt.Errorf("init trace exporter: %w", err) + } + shutdownFuncs = append(shutdownFuncs, traces.Shutdown) + + opts := []clue.Option{ + clue.WithResource(resource.Environment()), + clue.WithReaderInterval(30 * time.Second), + } + + cfg, err := clue.NewConfig(ctx, name, version, metrics, traces, opts...) + if err != nil { + return fmt.Errorf("init otel provider: %w", err) + } + clue.ConfigureOpenTelemetry(ctx, cfg) + + OTel = &OTelProvider{ + cfg: cfg, + shutdownFuncs: shutdownFuncs, + } + + if err := OTel.initMetrics(ctx); err != nil { + return fmt.Errorf("init metrics: %w", err) + } + + return nil +} + +func (i *OTelProvider) initMetrics(ctx context.Context) error { + if err := runtime.Start(); err != nil { + return fmt.Errorf("runtime: %w", err) + } + + up, err := OTel.Meter().Int64Gauge("up") + if err != nil { + return fmt.Errorf("up: %w", err) + } + up.Record(ctx, 1) + + return nil +} + +func (i *OTelProvider) Shutdown(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return ctx.Err() //nolint:wrapcheck + } + + var err error + for _, fn := range i.shutdownFuncs { + err = errors.Join(err, fn(ctx)) + } + + i.shutdownFuncs = nil + return err +} + +const scope = "github.com/jace-ys/countup/internal/instrument" + +func (i *OTelProvider) Meter() metric.Meter { + return i.cfg.MeterProvider.Meter(scope, metric.WithInstrumentationVersion(versioninfo.Version)) +} + +func (i *OTelProvider) Tracer() trace.Tracer { + return i.cfg.TracerProvider.Tracer(scope, trace.WithInstrumentationVersion(versioninfo.Version)) +} + +func (i *OTelProvider) Propagators() propagation.TextMapPropagator { + return i.cfg.Propagators +} diff --git a/app/internal/instrument/panic.go b/app/internal/instrument/panic.go new file mode 100644 index 0000000..59b9821 --- /dev/null +++ b/app/internal/instrument/panic.go @@ -0,0 +1,53 @@ +package instrument + +import ( + "context" + "fmt" + "sync" + + "github.com/go-chi/chi/v5/middleware" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/trace" + + "github.com/jace-ys/countup/internal/slog" +) + +var metrics struct { + init sync.Once + goPanicsRecoveredTotal metric.Int64Counter +} + +func initMetrics(ctx context.Context) { + metrics.init.Do(func() { + var err error + metrics.goPanicsRecoveredTotal, err = OTel.Meter().Int64Counter("go.panics.recovered.total") + if err != nil { + slog.Error(ctx, "error initializing metrics", err) + metrics.goPanicsRecoveredTotal, _ = noop.Meter{}.Int64Counter("go.panics.recovered.total") //nolint:errcheck + } + }) +} + +func EmitRecoveredPanicTelemetry(ctx context.Context, rvr any, source string) { + initMetrics(ctx) + + err := fmt.Errorf("%v", rvr) + + slog.Error(ctx, "recovered from panic", err, slog.KV("panic.source", source)) + middleware.PrintPrettyStack(rvr) + + attrs := []attribute.KeyValue{ + attribute.String("panic.source", source), + } + + span := trace.SpanFromContext(ctx) + span.SetStatus(codes.Error, "recovered from panic") + span.SetAttributes(attribute.Bool("panic.recovered", true)) + span.SetAttributes(attrs...) + span.RecordError(err, trace.WithStackTrace(true)) + + metrics.goPanicsRecoveredTotal.Add(ctx, 1, metric.WithAttributes(attrs...)) +} diff --git a/app/internal/postgres/pool.go b/app/internal/postgres/pool.go new file mode 100644 index 0000000..da87935 --- /dev/null +++ b/app/internal/postgres/pool.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "github.com/exaring/otelpgx" + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel/attribute" +) + +func NewPool(ctx context.Context, connString string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + attrs := []attribute.KeyValue{ + attribute.String("db.database", cfg.ConnConfig.Database), + } + + cfg.ConnConfig.Tracer = otelpgx.NewTracer( + otelpgx.WithAttributes(attrs...), + otelpgx.WithTrimSQLInSpanName(), + otelpgx.WithSpanNameFunc(func(stmt string) string { + idx := strings.IndexRune(stmt, '\n') + if idx >= 0 { + return stmt[:idx] + } + return stmt + }), + ) + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + return pool, nil +} diff --git a/app/internal/service/counter/errors.go b/app/internal/service/counter/errors.go new file mode 100644 index 0000000..afb6fd3 --- /dev/null +++ b/app/internal/service/counter/errors.go @@ -0,0 +1,30 @@ +package counter + +import ( + "errors" + "time" +) + +var ( + ErrDBConn = errors.New("db conn") + + ErrGetCounter = errors.New("store get counter") + ErrIncrementCounter = errors.New("store increment counter") + ErrUpdateCounterFinalizeTime = errors.New("store update counter finalize time") + ErrResetCounter = errors.New("store reset counter") + + ErrListIncrementRequests = errors.New("store list increment requests") + ErrInsertIncrementRequest = errors.New("store insert increment request") + ErrTruncateIncrementRequests = errors.New("store truncate increment requests") + + ErrEnqueueFinalizeIncrement = errors.New("enqueue finalize increment job") +) + +type MultipleIncrementRequestError struct { + User string + FinalizeWindow time.Duration +} + +func (e *MultipleIncrementRequestError) Error() string { + return "multiple increment request by user in finalize window" +} diff --git a/app/internal/service/counter/finalize.go b/app/internal/service/counter/finalize.go new file mode 100644 index 0000000..147d6b2 --- /dev/null +++ b/app/internal/service/counter/finalize.go @@ -0,0 +1,99 @@ +package counter + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + + counterstore "github.com/jace-ys/countup/internal/service/counter/store" + "github.com/jace-ys/countup/internal/slog" +) + +type FinalizeIncrementJobArgs struct { + FinalizeWindow time.Duration +} + +func (FinalizeIncrementJobArgs) Kind() string { + return "counter.FinalizeIncrement" +} + +type FinalizeIncrementWorker struct { + river.WorkerDefaults[FinalizeIncrementJobArgs] + + db *pgxpool.Pool + store counterstore.Querier +} + +func NewIncrementWorker(db *pgxpool.Pool, store counterstore.Querier) *FinalizeIncrementWorker { + return &FinalizeIncrementWorker{ + db: db, + store: store, + } +} + +func (w *FinalizeIncrementWorker) Work(ctx context.Context, job *river.Job[FinalizeIncrementJobArgs]) error { + tx, err := w.db.Begin(ctx) + if err != nil { + return fmt.Errorf("%w: tx begin: %w", ErrDBConn, err) + } + defer tx.Rollback(ctx) + + requests, err := w.store.ListIncrementRequests(ctx, tx) + if err != nil { + return fmt.Errorf("%w: %w", ErrListIncrementRequests, err) + } + + switch len(requests) { + case 0: + slog.Info(ctx, "no increment requests in finalize window, returning", + slog.KV("finalize.window", job.Args.FinalizeWindow), + ) + return nil + + case 1: + slog.Info(ctx, "only one increment request in finalize window, incrementing counter", + slog.KV("finalize.window", job.Args.FinalizeWindow), + slog.KV("finalize.user", requests[0].RequestedBy), + ) + + if err := w.store.IncrementCounter(ctx, tx, counterstore.IncrementCounterParams{ + LastIncrementBy: pgtype.Text{ + String: requests[0].RequestedBy, + Valid: true, + }, + LastIncrementAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }); err != nil { + return fmt.Errorf("%w: %w", ErrIncrementCounter, err) + } + + default: + slog.Info(ctx, "multiple increment requests in finalize window, resetting counter", + slog.KV("requests.count", len(requests)), + slog.KV("finalize.window", job.Args.FinalizeWindow), + slog.KV("finalize.user", requests[0].RequestedBy), + ) + + if err := w.store.ResetCounter(ctx, tx); err != nil { + return fmt.Errorf("%w: %w", ErrResetCounter, err) + } + } + + slog.Info(ctx, "truncating increment requests table") + + if err := w.store.TruncateIncrementRequests(ctx, tx); err != nil { + return fmt.Errorf("%w: %w", ErrTruncateIncrementRequests, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("%w: tx commit: %w", ErrDBConn, err) + } + + return nil +} diff --git a/app/internal/service/counter/service.go b/app/internal/service/counter/service.go new file mode 100644 index 0000000..ff380d2 --- /dev/null +++ b/app/internal/service/counter/service.go @@ -0,0 +1,131 @@ +package counter + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + counterstore "github.com/jace-ys/countup/internal/service/counter/store" + "github.com/jace-ys/countup/internal/slog" + "github.com/jace-ys/countup/internal/worker" +) + +type Service struct { + db *pgxpool.Pool + workers *worker.Pool + store counterstore.Querier + + finalizeWindow time.Duration +} + +func New(db *pgxpool.Pool, workers *worker.Pool, store counterstore.Querier, finalizeWindow time.Duration) *Service { + worker.Register(workers, &FinalizeIncrementWorker{ + db: db, + store: store, + }) + + return &Service{ + db: db, + workers: workers, + store: store, + finalizeWindow: finalizeWindow, + } +} + +func (s *Service) GetInfo(ctx context.Context) (*Info, error) { + slog.Info(ctx, "getting counter") + + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("%w: tx begin: %w", ErrDBConn, err) + } + defer tx.Rollback(ctx) + + counter, err := s.store.GetCounter(ctx, tx) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrGetCounter, err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("%w: tx commit: %w", ErrDBConn, err) + } + + return &Info{ + Count: counter.Count, + LastIncrementBy: counter.LastIncrementBy.String, + LastIncrementAt: counter.LastIncrementAt.Time, + NextFinalizeAt: counter.NextFinalizeAt.Time, + }, nil +} + +func (s *Service) RequestIncrement(ctx context.Context, user string) error { + ctx = slog.With(ctx, slog.KV("request.user", user)) + + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("%w: tx begin: %w", ErrDBConn, err) + } + defer tx.Rollback(ctx) + + slog.Info(ctx, "inserting increment request") + + existing, err := s.store.InsertIncrementRequest(ctx, tx, counterstore.InsertIncrementRequestParams{ + RequestedBy: user, + RequestedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case pgerrcode.UniqueViolation: + return &MultipleIncrementRequestError{user, s.finalizeWindow} + } + } + return fmt.Errorf("%w: %w", ErrInsertIncrementRequest, err) + } + + if existing > 0 { + slog.Info(ctx, "existing increment requests in finalize window, skip enqueuing finalize job", + slog.KV("requests.count", existing), + slog.KV("finalize.window", s.finalizeWindow), + ) + } else { + slog.Info(ctx, "first increment request in finalize window, enqueuing finalize job", + slog.KV("finalize.window", s.finalizeWindow), + ) + + finalizeAt := time.Now().Add(s.finalizeWindow) + + if err := s.workers.EnqueueTx(ctx, tx, FinalizeIncrementJobArgs{ + FinalizeWindow: s.finalizeWindow, + }, + worker.WithSchedule(finalizeAt), + ); err != nil { + return fmt.Errorf("%w: %w", ErrEnqueueFinalizeIncrement, err) + } + + slog.Info(ctx, "updating counter finalize time", slog.KV("finalize.at", finalizeAt)) + + if err := s.store.UpdateCounterFinalizeTime(ctx, tx, pgtype.Timestamptz{ + Time: finalizeAt, + Valid: true, + }); err != nil { + return fmt.Errorf("%w: %w", ErrUpdateCounterFinalizeTime, err) + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("%w: tx commit: %w", ErrDBConn, err) + } + + return nil +} diff --git a/app/internal/service/counter/store/counter.sql.go b/app/internal/service/counter/store/counter.sql.go new file mode 100644 index 0000000..e7606cf --- /dev/null +++ b/app/internal/service/counter/store/counter.sql.go @@ -0,0 +1,136 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: counter.sql + +package counterstore + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getCounter = `-- name: GetCounter :one +SELECT id, count, last_increment_by, last_increment_at, next_finalize_at +FROM counter +WHERE id = 1 +` + +func (q *Queries) GetCounter(ctx context.Context, db DBTX) (Counter, error) { + row := db.QueryRow(ctx, getCounter) + var i Counter + err := row.Scan( + &i.ID, + &i.Count, + &i.LastIncrementBy, + &i.LastIncrementAt, + &i.NextFinalizeAt, + ) + return i, err +} + +const incrementCounter = `-- name: IncrementCounter :exec +UPDATE counter +SET + count = count + 1, + last_increment_by = $1, + last_increment_at = $2, + next_finalize_at = NULL +WHERE id = 1 +` + +type IncrementCounterParams struct { + LastIncrementBy pgtype.Text + LastIncrementAt pgtype.Timestamptz +} + +func (q *Queries) IncrementCounter(ctx context.Context, db DBTX, arg IncrementCounterParams) error { + _, err := db.Exec(ctx, incrementCounter, arg.LastIncrementBy, arg.LastIncrementAt) + return err +} + +const insertIncrementRequest = `-- name: InsertIncrementRequest :one +WITH inserted AS ( + INSERT INTO increment_requests (requested_by, requested_at) + VALUES ($1, $2) +), existing AS ( + SELECT COUNT(*) AS requests FROM increment_requests +) +SELECT + existing.requests AS existing_requests +FROM existing +` + +type InsertIncrementRequestParams struct { + RequestedBy string + RequestedAt pgtype.Timestamptz +} + +func (q *Queries) InsertIncrementRequest(ctx context.Context, db DBTX, arg InsertIncrementRequestParams) (int64, error) { + row := db.QueryRow(ctx, insertIncrementRequest, arg.RequestedBy, arg.RequestedAt) + var existing_requests int64 + err := row.Scan(&existing_requests) + return existing_requests, err +} + +const listIncrementRequests = `-- name: ListIncrementRequests :many +SELECT requested_by, requested_at +FROM increment_requests +` + +func (q *Queries) ListIncrementRequests(ctx context.Context, db DBTX) ([]IncrementRequest, error) { + rows, err := db.Query(ctx, listIncrementRequests) + if err != nil { + return nil, err + } + defer rows.Close() + var items []IncrementRequest + for rows.Next() { + var i IncrementRequest + if err := rows.Scan(&i.RequestedBy, &i.RequestedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resetCounter = `-- name: ResetCounter :exec +UPDATE counter +SET + count = 0, + last_increment_by = NULL, + last_increment_at = NULL, + next_finalize_at = NULL +WHERE id = 1 +` + +func (q *Queries) ResetCounter(ctx context.Context, db DBTX) error { + _, err := db.Exec(ctx, resetCounter) + return err +} + +const truncateIncrementRequests = `-- name: TruncateIncrementRequests :exec +TRUNCATE TABLE increment_requests +` + +func (q *Queries) TruncateIncrementRequests(ctx context.Context, db DBTX) error { + _, err := db.Exec(ctx, truncateIncrementRequests) + return err +} + +const updateCounterFinalizeTime = `-- name: UpdateCounterFinalizeTime :exec +UPDATE counter +SET + next_finalize_at = $1 +WHERE id = 1 +` + +func (q *Queries) UpdateCounterFinalizeTime(ctx context.Context, db DBTX, nextFinalizeAt pgtype.Timestamptz) error { + _, err := db.Exec(ctx, updateCounterFinalizeTime, nextFinalizeAt) + return err +} diff --git a/app/internal/service/counter/store/db.go b/app/internal/service/counter/store/db.go new file mode 100644 index 0000000..26956fd --- /dev/null +++ b/app/internal/service/counter/store/db.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package counterstore + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New() *Queries { + return &Queries{} +} + +type Queries struct { +} diff --git a/app/internal/service/counter/store/models.go b/app/internal/service/counter/store/models.go new file mode 100644 index 0000000..3bbc047 --- /dev/null +++ b/app/internal/service/counter/store/models.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package counterstore + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Counter struct { + ID int32 + Count int32 + LastIncrementBy pgtype.Text + LastIncrementAt pgtype.Timestamptz + NextFinalizeAt pgtype.Timestamptz +} + +type IncrementRequest struct { + RequestedBy string + RequestedAt pgtype.Timestamptz +} diff --git a/app/internal/service/counter/store/querier.go b/app/internal/service/counter/store/querier.go new file mode 100644 index 0000000..562a746 --- /dev/null +++ b/app/internal/service/counter/store/querier.go @@ -0,0 +1,23 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package counterstore + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + GetCounter(ctx context.Context, db DBTX) (Counter, error) + IncrementCounter(ctx context.Context, db DBTX, arg IncrementCounterParams) error + InsertIncrementRequest(ctx context.Context, db DBTX, arg InsertIncrementRequestParams) (int64, error) + ListIncrementRequests(ctx context.Context, db DBTX) ([]IncrementRequest, error) + ResetCounter(ctx context.Context, db DBTX) error + TruncateIncrementRequests(ctx context.Context, db DBTX) error + UpdateCounterFinalizeTime(ctx context.Context, db DBTX, nextFinalizeAt pgtype.Timestamptz) error +} + +var _ Querier = (*Queries)(nil) diff --git a/app/internal/service/counter/types.go b/app/internal/service/counter/types.go new file mode 100644 index 0000000..ca3dae2 --- /dev/null +++ b/app/internal/service/counter/types.go @@ -0,0 +1,24 @@ +package counter + +import "time" + +type Info struct { + Count int32 + LastIncrementBy string + LastIncrementAt time.Time + NextFinalizeAt time.Time +} + +func (i *Info) LastIncrementAtTimestamp() string { + if i.LastIncrementAt.IsZero() { + return "" + } + return i.LastIncrementAt.String() +} + +func (i *Info) NextFinalizeAtTimestamp() string { + if i.NextFinalizeAt.IsZero() { + return "" + } + return i.NextFinalizeAt.String() +} diff --git a/app/internal/slog/clue.go b/app/internal/slog/clue.go new file mode 100644 index 0000000..5a8825b --- /dev/null +++ b/app/internal/slog/clue.go @@ -0,0 +1,61 @@ +package slog + +import ( + "context" + "io" + + "goa.design/clue/log" +) + +func NewContext(ctx context.Context, w io.Writer, debug bool) context.Context { + format := log.FormatJSON + if log.IsTerminal() { + format = log.FormatTerminal + } + + opts := []log.LogOption{ + log.WithOutput(w), + log.WithFormat(format), + log.WithFileLocation(), + log.WithFunc(log.Span), + } + + if debug { + opts = append(opts, log.WithDebug()) + } + + return log.Context(ctx, opts...) +} + +func KV(key string, val any) log.KV { + return log.KV{K: key, V: val} +} + +func With(ctx context.Context, kv ...log.Fielder) context.Context { + return log.With(ctx, kv...) +} + +func Print(ctx context.Context, msg string, kv ...log.Fielder) { + log.Print(ctx, buildKVs(msg, kv...)...) +} + +func Debug(ctx context.Context, msg string, kv ...log.Fielder) { + log.Debug(ctx, buildKVs(msg, kv...)...) +} + +func Info(ctx context.Context, msg string, kv ...log.Fielder) { + log.Info(ctx, buildKVs(msg, kv...)...) +} + +func Error(ctx context.Context, msg string, err error, kv ...log.Fielder) { + log.Error(ctx, err, buildKVs(msg, kv...)...) +} + +func Fatal(ctx context.Context, msg string, err error, kv ...log.Fielder) { + log.Fatal(ctx, err, buildKVs(msg, kv...)...) +} + +func buildKVs(msg string, kv ...log.Fielder) []log.Fielder { + kvs := []log.Fielder{KV(log.MessageKey, msg)} + return append(kvs, kv...) +} diff --git a/app/internal/slog/middleware.go b/app/internal/slog/middleware.go new file mode 100644 index 0000000..ce10e43 --- /dev/null +++ b/app/internal/slog/middleware.go @@ -0,0 +1,47 @@ +package slog + +import ( + "context" + "net/http" + + "goa.design/clue/log" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + + "github.com/jace-ys/countup/internal/transport/middleware/idgen" +) + +func UnaryServerInterceptor(logCtx context.Context) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (_ any, err error) { + requestID := idgen.RequestIDFromContext(ctx) + + ctx = log.WithContext(ctx, logCtx) + ctx = log.With(ctx, KV(log.RequestIDKey, requestID)) + + opts := []log.GRPCLogOption{ + log.WithErrorFunc(func(c codes.Code) bool { return false }), + log.WithDisableCallID(), + } + + return log.UnaryServerInterceptor(ctx, opts...)(ctx, req, info, next) + } +} + +func HTTP(logCtx context.Context) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + requestID := idgen.RequestIDFromContext(ctx) + + ctx = log.WithContext(ctx, logCtx) + ctx = log.With(ctx, KV(log.RequestIDKey, requestID)) + + opts := []log.HTTPLogOption{ + log.WithDisableRequestID(), + } + + handler := log.HTTP(ctx, opts...)(next) + handler.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/app/internal/slog/stdslog.go b/app/internal/slog/stdslog.go new file mode 100644 index 0000000..ef5e0b5 --- /dev/null +++ b/app/internal/slog/stdslog.go @@ -0,0 +1,105 @@ +package slog + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "goa.design/clue/log" +) + +type StdSlogHandler struct { + ctx context.Context + level slog.Level + group string +} + +func AsStdSlogHandler(ctx context.Context, level slog.Level) *StdSlogHandler { + return &StdSlogHandler{ + ctx: log.Context(ctx, log.WithDebug()), + level: level, + } +} + +var _ slog.Handler = (*StdSlogHandler)(nil) + +func (l *StdSlogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= l.level +} + +func (l *StdSlogHandler) Handle(ctx context.Context, record slog.Record) error { + var attrs []log.Fielder + record.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, KV(a.Key, a.Value.Any())) + return true + }) + + logCtx := log.WithContext(ctx, l.ctx) + logCtx = log.With(logCtx, attrs...) + + switch record.Level { + case slog.LevelDebug: + Debug(logCtx, record.Message) + case slog.LevelInfo: + Info(logCtx, record.Message) + case slog.LevelWarn: + Error(logCtx, "warn", errors.New(record.Message)) + case slog.LevelError: + Error(logCtx, "error", errors.New(record.Message)) + default: + Print(logCtx, record.Message) + } + return nil +} + +func (l *StdSlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + kvs := make([]log.Fielder, len(attrs)) + for i, attr := range attrs { + if l.group != "" { + kvs[i] = KV(fmt.Sprintf("%s.%s", l.group, attr.Key), attr.Value.Any()) + } else { + kvs[i] = KV(attr.Key, attr.Value.Any()) + } + } + + cp := l.clone() + cp.ctx = log.With(cp.ctx, kvs...) + return cp +} + +func (l *StdSlogHandler) WithGroup(name string) slog.Handler { + cp := l.clone() + cp.group = name + return cp +} + +func (l *StdSlogHandler) clone() *StdSlogHandler { + cp := *l + return &cp +} + +type NopHandler struct { +} + +func AsNopHandler() *NopHandler { + return &NopHandler{} +} + +var _ slog.Handler = (*NopHandler)(nil) + +func (l NopHandler) Enabled(ctx context.Context, level slog.Level) bool { + return false +} + +func (l NopHandler) Handle(ctx context.Context, record slog.Record) error { + return nil +} + +func (l NopHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return l +} + +func (l NopHandler) WithGroup(name string) slog.Handler { + return l +} diff --git a/app/internal/slog/stdslog_test.go b/app/internal/slog/stdslog_test.go new file mode 100644 index 0000000..b7c570b --- /dev/null +++ b/app/internal/slog/stdslog_test.go @@ -0,0 +1,184 @@ +package slog_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + stdslog "log/slog" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jace-ys/countup/internal/slog" +) + +func TestStdSlogHandler(t *testing.T) { + buf := &bytes.Buffer{} + ctx := slog.NewContext(context.Background(), buf, false) + + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelDebug)) + + logger.Debug("test debug", "count", 1.0) + logger.Info("test info", "count", 2.0) + logger.Warn("test warn", "count", 3.0) + logger.Error("test error", "count", 4.0) + + assertLogOutput(t, buf, []map[string]any{ + {"level": "debug", "msg": "test debug", "count": 1.0}, + {"level": "info", "msg": "test info", "count": 2.0}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0}, + }) +} + +func TestStdSlogHandlerLevel(t *testing.T) { + buf := &bytes.Buffer{} + ctx := slog.NewContext(context.Background(), buf, false) + + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelWarn)) + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + assertLogOutput(t, buf, []map[string]any{ + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0}, + }) +} + +func TestStdSlogHandlerContext(t *testing.T) { + buf := &bytes.Buffer{} + ctx := slog.NewContext(context.Background(), buf, false) + ctx = slog.With(ctx, slog.KV("foo", "bar")) + + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelDebug)) + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + ctx = slog.With(ctx, slog.KV("ping", "pong")) + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + assertLogOutput(t, buf, []map[string]any{ + {"level": "debug", "msg": "test debug", "count": 1.0, "foo": "bar"}, + {"level": "info", "msg": "test info", "count": 2.0, "foo": "bar"}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0, "foo": "bar"}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0, "foo": "bar"}, + {"level": "debug", "msg": "test debug", "count": 1.0, "foo": "bar"}, + {"level": "info", "msg": "test info", "count": 2.0, "foo": "bar"}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0, "foo": "bar"}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0, "foo": "bar"}, + }) +} + +func TestStdSlogHandlerWith(t *testing.T) { + buf := &bytes.Buffer{} + ctx := slog.NewContext(context.Background(), buf, false) + + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelDebug)) + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + logger = logger.With("ping", "pong") + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + assertLogOutput(t, buf, []map[string]any{ + {"level": "debug", "msg": "test debug", "count": 1.0}, + {"level": "info", "msg": "test info", "count": 2.0}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0}, + {"level": "debug", "msg": "test debug", "count": 1.0, "ping": "pong"}, + {"level": "info", "msg": "test info", "count": 2.0, "ping": "pong"}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0, "ping": "pong"}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0, "ping": "pong"}, + }) +} + +func TestStdSlogHandlerGroup(t *testing.T) { + buf := &bytes.Buffer{} + ctx := slog.NewContext(context.Background(), buf, false) + + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelDebug)) + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + logger = logger.WithGroup("foo").With("bar", "baz").WithGroup("ping").With("pong", "table") + + logger.DebugContext(ctx, "test debug", "count", 1.0) + logger.InfoContext(ctx, "test info", "count", 2.0) + logger.WarnContext(ctx, "test warn", "count", 3.0) + logger.ErrorContext(ctx, "test error", "count", 4.0) + + assertLogOutput(t, buf, []map[string]any{ + {"level": "debug", "msg": "test debug", "count": 1.0}, + {"level": "info", "msg": "test info", "count": 2.0}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0}, + {"level": "debug", "msg": "test debug", "count": 1.0, "foo.bar": "baz", "ping.pong": "table"}, + {"level": "info", "msg": "test info", "count": 2.0, "foo.bar": "baz", "ping.pong": "table"}, + {"level": "error", "msg": "warn", "err": "test warn", "count": 3.0, "foo.bar": "baz", "ping.pong": "table"}, + {"level": "error", "msg": "error", "err": "test error", "count": 4.0, "foo.bar": "baz", "ping.pong": "table"}, + }) +} + +func TestStdSlogHandlerConcurrent(t *testing.T) { + ctx := slog.NewContext(context.Background(), io.Discard, false) + logger := stdslog.New(slog.AsStdSlogHandler(ctx, stdslog.LevelDebug)) + + var wg sync.WaitGroup + for i := range 100 { + wg.Add(1) + go func(id int) { + defer wg.Done() + + if id%2 == 0 { + logger.Info("table", "ping", "pong") + } else { + logger.WithGroup("foo").With("bar", "baz").Info("table", "ping", "pong") + } + }(i) + } + + wg.Wait() +} + +func assertLogOutput(t *testing.T, r io.Reader, expected []map[string]any) { + scanner := bufio.NewScanner(r) + var actual []map[string]any + + for scanner.Scan() { + var data map[string]any + require.NoError(t, json.Unmarshal(scanner.Bytes(), &data)) + delete(data, "time") + delete(data, "file") + actual = append(actual, data) + } + + require.Len(t, actual, len(expected)) + + for i, data := range actual { + assert.Equal(t, expected[i], data) + } +} diff --git a/app/internal/transport/goa.go b/app/internal/transport/goa.go new file mode 100644 index 0000000..716d97c --- /dev/null +++ b/app/internal/transport/goa.go @@ -0,0 +1,99 @@ +package transport + +import ( + "context" + "fmt" + "io/fs" + "net/http" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "goa.design/clue/log" + "goa.design/goa/v3/grpc" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + + "github.com/jace-ys/countup/internal/endpoints" + "github.com/jace-ys/countup/internal/slog" +) + +type GoaGRPCAdapter[E endpoints.GoaEndpoints, S any] struct { + newFunc GoaGRPCNewFunc[E, S] +} + +type GoaGRPCNewFunc[E endpoints.GoaEndpoints, S any] func(e E, uh grpc.UnaryHandler) S + +func GoaGRPC[E endpoints.GoaEndpoints, S any](newFunc GoaGRPCNewFunc[E, S]) *GoaGRPCAdapter[E, S] { + return &GoaGRPCAdapter[E, S]{ + newFunc: newFunc, + } +} + +func (a *GoaGRPCAdapter[E, S]) Adapt(ctx context.Context, ep E) S { + srv := a.newFunc(ep, nil) + return srv +} + +type GoaHTTPServer interface { + MethodNames() []string + Mount(mux goahttp.Muxer) + Service() string + Use(m func(http.Handler) http.Handler) +} + +type GoaHTTPAdapter[E endpoints.GoaEndpoints, S GoaHTTPServer] struct { + newFunc GoaHTTPNewFunc[E, S] + mountFunc GoaHTTPMountFunc[S] +} + +type GoaHTTPNewFunc[E endpoints.GoaEndpoints, S GoaHTTPServer] func( + e E, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + files http.FileSystem, +) S + +type GoaHTTPMountFunc[S GoaHTTPServer] func( + mux goahttp.Muxer, + srv S, +) + +func GoaHTTP[E endpoints.GoaEndpoints, S GoaHTTPServer](newFunc GoaHTTPNewFunc[E, S], mountFunc GoaHTTPMountFunc[S]) *GoaHTTPAdapter[E, S] { + return &GoaHTTPAdapter[E, S]{ + newFunc: newFunc, + mountFunc: mountFunc, + } +} + +func (a *GoaHTTPAdapter[E, S]) Adapt(ctx context.Context, ep E, files fs.FS) goahttp.ResolverMuxer { + dec := goahttp.RequestDecoder + enc := goahttp.ResponseEncoder + formatter := goahttp.NewErrorResponse + + eh := func(ctx context.Context, w http.ResponseWriter, err error) { + slog.Error(ctx, "failed to encode response", err, + slog.KV(log.GoaMethodKey, ctx.Value(goa.MethodKey)), + slog.KV(log.GoaServiceKey, ctx.Value(goa.ServiceKey)), + ) + + gerr := goa.Fault("failed to encode response") + + span := trace.SpanFromContext(ctx) + span.SetStatus(codes.Error, gerr.GoaErrorName()) + span.SetAttributes(attribute.String("error", fmt.Sprintf("failed to encode response: %v", err))) + + if err := goahttp.ErrorEncoder(enc, formatter)(ctx, w, gerr); err != nil { + panic(err) + } + } + + mux := goahttp.NewMuxer() + srv := a.newFunc(ep, mux, dec, enc, eh, formatter, http.FS(files)) + a.mountFunc(mux, srv) + + return mux +} diff --git a/app/internal/transport/middleware/idgen/request.go b/app/internal/transport/middleware/idgen/request.go new file mode 100644 index 0000000..49813de --- /dev/null +++ b/app/internal/transport/middleware/idgen/request.go @@ -0,0 +1,56 @@ +package idgen + +import ( + "context" + "crypto/rand" + "encoding/base64" + "io" + "net/http" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "goa.design/clue/log" + "google.golang.org/grpc" +) + +type ctxRequestIDKey struct{} + +func UnaryServerInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (_ any, err error) { + ctx = newRequestID(ctx) + return next(ctx, req) + } +} + +func HTTP() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := newRequestID(r.Context()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func newRequestID(ctx context.Context) context.Context { + requestID := shortIDGen() + ctx = context.WithValue(ctx, ctxRequestIDKey{}, requestID) + + span := trace.SpanFromContext(ctx) + span.SetAttributes(attribute.String(log.RequestIDKey, requestID)) + + return ctx +} + +func shortIDGen() string { + b := make([]byte, 6) + io.ReadFull(rand.Reader, b) //nolint:errcheck + return base64.RawURLEncoding.EncodeToString(b) +} + +func RequestIDFromContext(ctx context.Context) string { + requestID, ok := ctx.Value(ctxRequestIDKey{}).(string) + if !ok { + return "null" + } + return requestID +} diff --git a/app/internal/transport/middleware/recovery/recovery.go b/app/internal/transport/middleware/recovery/recovery.go new file mode 100644 index 0000000..14e3e8d --- /dev/null +++ b/app/internal/transport/middleware/recovery/recovery.go @@ -0,0 +1,53 @@ +package recovery + +import ( + "context" + "errors" + "fmt" + "net/http" + + "goa.design/clue/log" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/jace-ys/countup/internal/instrument" +) + +func UnaryServerInterceptor(logCtx context.Context) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (_ any, err error) { + defer func() { + if rvr := recover(); rvr != nil { + ctx := log.WithContext(ctx, logCtx) + instrument.EmitRecoveredPanicTelemetry(ctx, rvr, info.FullMethod[1:]) + err = status.Error(codes.Internal, "internal server error") + } + }() + + return next(ctx, req) + } +} + +func HTTP(logCtx context.Context) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + if err, ok := rvr.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(rvr) + } + + ctx := log.WithContext(r.Context(), logCtx) + source := fmt.Sprintf("%s %s", r.Method, r.URL.Path) + instrument.EmitRecoveredPanicTelemetry(ctx, rvr, source) + + if r.Header.Get("Connection") != "Upgrade" { + w.WriteHeader(http.StatusInternalServerError) + } + } + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/app/internal/transport/middleware/telemetry/http.go b/app/internal/transport/middleware/telemetry/http.go new file mode 100644 index 0000000..5e47fcb --- /dev/null +++ b/app/internal/transport/middleware/telemetry/http.go @@ -0,0 +1,25 @@ +package telemetry + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +func HTTP(attrs ...attribute.KeyValue) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + operation := fmt.Sprintf("%s %s", r.Method, r.URL.Path) + + opts := []otelhttp.Option{ + otelhttp.WithSpanOptions(trace.WithAttributes(attrs...)), + } + + handler := otelhttp.NewHandler(otelhttp.WithRouteTag(r.URL.Path, next), operation, opts...) + handler.ServeHTTP(w, r) + }) + } +} diff --git a/app/internal/versioninfo/version.go b/app/internal/versioninfo/version.go new file mode 100644 index 0000000..2ed0475 --- /dev/null +++ b/app/internal/versioninfo/version.go @@ -0,0 +1,6 @@ +package versioninfo + +var ( + Version = "dev" + CommitSHA = "none" +) diff --git a/app/internal/worker/instrumented.go b/app/internal/worker/instrumented.go new file mode 100644 index 0000000..541382f --- /dev/null +++ b/app/internal/worker/instrumented.go @@ -0,0 +1,159 @@ +package worker + +import ( + "context" + "fmt" + "strings" + + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + "goa.design/clue/log" + + "github.com/jace-ys/countup/internal/instrument" + "github.com/jace-ys/countup/internal/slog" +) + +type instrumentedWorkerMiddleware struct { + river.WorkerMiddlewareDefaults +} + +var _ rivertype.WorkerMiddleware = (*instrumentedWorkerMiddleware)(nil) + +func (w *instrumentedWorkerMiddleware) Work(ctx context.Context, job *rivertype.JobRow, doInner func(context.Context) error) error { + kvs := []log.Fielder{ + slog.KV("job.worker", "river"), + slog.KV("job.kind", job.Kind), + slog.KV("job.id", job.ID), + slog.KV("job.attempt", job.Attempt), + } + + attrs := []attribute.KeyValue{ + attribute.String("job.worker", "river"), + attribute.String("job.kind", job.Kind), + attribute.Int64("job.id", job.ID), + attribute.Int("job.attempt", job.Attempt), + attribute.String("job.queue", job.Queue), + attribute.Int("job.priority", job.Priority), + attribute.String("job.scheduled_at", job.ScheduledAt.String()), + attribute.String("job.attempted_at", job.AttemptedAt.String()), + } + + md, err := parseMetadata(job.Metadata) + if err != nil { + slog.Error(ctx, "failed to extract metadata from job", err) + } + + for k, v := range md { + kvs = append(kvs, slog.KV(k, v)) + attrs = append(attrs, attribute.String(k, v)) + } + + source := fmt.Sprintf("river.worker/%s", job.Kind) + ctx, span := instrument.OTel.Tracer().Start(ctx, source) + span.SetAttributes(attrs...) + defer span.End() + + ctx = slog.With(ctx, kvs...) + slog.Print(ctx, "job started", + slog.KV("job.queue", job.Queue), + slog.KV("job.priority", job.Priority), + slog.KV("job.scheduled_at", job.ScheduledAt), + slog.KV("job.attempted_at", job.AttemptedAt), + ) + + func() { + defer func() { + if rvr := recover(); rvr != nil { + instrument.EmitRecoveredPanicTelemetry(ctx, rvr, source) + err = fmt.Errorf("%v", rvr) + } + }() + err = doInner(ctx) + }() + + if err != nil { + var errReason string + switch { + case strings.HasPrefix(err.Error(), "jobCancelError:"): + errReason = "job cancelled" + span.SetAttributes(attribute.Bool("job.cancelled", true)) + + case strings.HasPrefix(err.Error(), "jobSnoozeError:"): + slog.Print(ctx, "job snoozed") + span.SetAttributes(attribute.Bool("job.snoozed", true)) + return err + + case job.Attempt == job.MaxAttempts: + errReason = "job failed, discarded due to max attempts exceeded" + span.SetAttributes(attribute.Bool("job.discarded", true)) + + default: + errReason = "job failed" + span.SetAttributes(attribute.Bool("job.failed", true)) + } + + slog.Error(ctx, errReason, err) + span.SetStatus(codes.Error, errReason) + span.SetAttributes(attribute.String("error", err.Error())) + + return err + } + + slog.Print(ctx, "job completed") + return nil +} + +type instrumentedJobInsertMiddleware struct { + river.JobInsertMiddlewareDefaults + metrics *metrics +} + +var _ rivertype.JobInsertMiddleware = (*instrumentedJobInsertMiddleware)(nil) + +func (m *instrumentedJobInsertMiddleware) InsertMany(ctx context.Context, manyParams []*rivertype.JobInsertParams, doInner func(ctx context.Context) ([]*rivertype.JobInsertResult, error)) ([]*rivertype.JobInsertResult, error) { + results, err := doInner(ctx) + for _, enqueued := range results { + m.emitEnqueuedTelemetry(ctx, enqueued) + } + return results, err +} + +func (m *instrumentedJobInsertMiddleware) emitEnqueuedTelemetry(ctx context.Context, enqueued *rivertype.JobInsertResult) { + kvs := []log.Fielder{ + slog.KV("job.worker", "river"), + slog.KV("job.kind", enqueued.Job.Kind), + slog.KV("job.id", enqueued.Job.ID), + slog.KV("job.queue", enqueued.Job.Queue), + slog.KV("job.priority", enqueued.Job.Priority), + slog.KV("job.scheduled_at", enqueued.Job.ScheduledAt), + slog.KV("job.max_attempts", enqueued.Job.MaxAttempts), + } + + attrs := []attribute.KeyValue{ + attribute.String("job.worker", "river"), + attribute.String("job.kind", enqueued.Job.Kind), + attribute.String("job.queue", enqueued.Job.Queue), + attribute.Int("job.priority", enqueued.Job.Priority), + } + + slog.Print(ctx, "job enqueued", kvs...) + + span := trace.SpanFromContext(ctx) + span.AddEvent("job.enqueued", + trace.WithTimestamp(enqueued.Job.CreatedAt), + trace.WithAttributes(attrs...), + trace.WithAttributes( + attribute.Int64("job.id", enqueued.Job.ID), + attribute.String("job.scheduled_at", enqueued.Job.ScheduledAt.String()), + attribute.Int("job.max_attempts", enqueued.Job.MaxAttempts), + ), + ) + + attrset := attribute.NewSet(attrs...) + m.metrics.jobsEnqueuedTotal.Add(ctx, 1, metric.WithAttributeSet(attrset)) + m.metrics.jobsAvailableCount.Add(ctx, 1, metric.WithAttributeSet(attrset)) +} diff --git a/app/internal/worker/metadata.go b/app/internal/worker/metadata.go new file mode 100644 index 0000000..7208594 --- /dev/null +++ b/app/internal/worker/metadata.go @@ -0,0 +1,31 @@ +package worker + +import ( + "context" + "encoding/json" + + "go.opentelemetry.io/otel/propagation" + "goa.design/clue/log" + + "github.com/jace-ys/countup/internal/instrument" + "github.com/jace-ys/countup/internal/transport/middleware/idgen" +) + +var _ propagation.TextMapCarrier = (*JobMetadata)(nil) + +type JobMetadata = propagation.MapCarrier + +func withContextMetadata(ctx context.Context) EnqueueOption { + md := make(JobMetadata) + + md[log.RequestIDKey] = idgen.RequestIDFromContext(ctx) + instrument.OTel.Propagators().Inject(ctx, md) + + return WithMetadata(ctx, md) +} + +func parseMetadata(metadata []byte) (JobMetadata, error) { + md := make(JobMetadata) + err := json.Unmarshal(metadata, &md) + return md, err //nolint:wrapcheck +} diff --git a/app/internal/worker/metrics.go b/app/internal/worker/metrics.go new file mode 100644 index 0000000..2ac2538 --- /dev/null +++ b/app/internal/worker/metrics.go @@ -0,0 +1,124 @@ +package worker + +import ( + "context" + "fmt" + + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/jace-ys/countup/internal/instrument" +) + +type metrics struct { + jobsEnqueuedTotal metric.Int64Counter + jobsCompletedTotal metric.Int64Counter + jobsFailedTotal metric.Int64Counter + jobsDiscardedTotal metric.Int64Counter + jobsCancelledTotal metric.Int64Counter + jobsAvailableCount metric.Int64UpDownCounter + jobsRunDurationSeconds metric.Float64Histogram + jobsQueueWaitMilliseconds metric.Int64Histogram +} + +func newMetrics() (*metrics, error) { + meter := instrument.OTel.Meter() + + metrics := new(metrics) + var err error + + metrics.jobsEnqueuedTotal, err = meter.Int64Counter("worker.jobs.enqueued.total") + if err != nil { + return nil, fmt.Errorf("worker.jobs.enqueued.total: %w", err) + } + + metrics.jobsCompletedTotal, err = meter.Int64Counter("worker.jobs.completed.total") + if err != nil { + return nil, fmt.Errorf("worker.jobs.completed.total: %w", err) + } + + metrics.jobsFailedTotal, err = meter.Int64Counter("worker.jobs.failed.total") + if err != nil { + return nil, fmt.Errorf("worker.jobs.failed.total: %w", err) + } + + metrics.jobsDiscardedTotal, err = meter.Int64Counter("worker.jobs.discarded.total") + if err != nil { + return nil, fmt.Errorf("worker.jobs.discarded.total: %w", err) + } + + metrics.jobsCancelledTotal, err = meter.Int64Counter("worker.jobs.cancelled.total") + if err != nil { + return nil, fmt.Errorf("worker.jobs.cancelled.total: %w", err) + } + + metrics.jobsAvailableCount, err = meter.Int64UpDownCounter("worker.jobs.available.count") + if err != nil { + return nil, fmt.Errorf("worker.jobs.available.count: %w", err) + } + + metrics.jobsRunDurationSeconds, err = meter.Float64Histogram("worker.jobs.run.duration.seconds") + if err != nil { + return nil, fmt.Errorf("worker.jobs.run.duration.seconds: %w", err) + } + + metrics.jobsQueueWaitMilliseconds, err = meter.Int64Histogram("worker.jobs.queue.wait.milliseconds") + if err != nil { + return nil, fmt.Errorf("worker.jobs.queue.wait.milliseconds: %w", err) + } + + return metrics, nil +} + +func (p *Pool) runMetricsExporter(ctx context.Context) { + subscriptions := []river.EventKind{ + river.EventKindJobCompleted, + river.EventKindJobCancelled, + river.EventKindJobFailed, + } + + events, cancel := p.pool.Subscribe(subscriptions...) + defer cancel() + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + + attrs := attribute.NewSet([]attribute.KeyValue{ + attribute.String("job.worker", "river"), + attribute.String("job.kind", event.Job.Kind), + attribute.String("job.queue", event.Job.Queue), + attribute.Int("job.priority", event.Job.Priority), + }...) + + p.metrics.jobsQueueWaitMilliseconds.Record(ctx, event.JobStats.QueueWaitDuration.Milliseconds(), metric.WithAttributeSet(attrs)) + + switch event.Kind { //nolint:exhaustive + case river.EventKindJobCompleted: + p.metrics.jobsCompletedTotal.Add(ctx, 1, metric.WithAttributeSet(attrs)) + p.metrics.jobsAvailableCount.Add(ctx, -1, metric.WithAttributeSet(attrs)) + p.metrics.jobsRunDurationSeconds.Record(ctx, event.JobStats.RunDuration.Seconds(), metric.WithAttributeSet(attrs)) + + case river.EventKindJobCancelled: + p.metrics.jobsCancelledTotal.Add(ctx, 1, metric.WithAttributeSet(attrs)) + p.metrics.jobsAvailableCount.Add(ctx, -1, metric.WithAttributeSet(attrs)) + + case river.EventKindJobFailed: + p.metrics.jobsFailedTotal.Add(ctx, 1, metric.WithAttributeSet(attrs)) + + switch event.Job.State { //nolint:exhaustive + case rivertype.JobStateDiscarded: + p.metrics.jobsDiscardedTotal.Add(ctx, 1, metric.WithAttributeSet(attrs)) + p.metrics.jobsAvailableCount.Add(ctx, -1, metric.WithAttributeSet(attrs)) + } + } + } + } +} diff --git a/app/internal/worker/options.go b/app/internal/worker/options.go new file mode 100644 index 0000000..c00c762 --- /dev/null +++ b/app/internal/worker/options.go @@ -0,0 +1,47 @@ +package worker + +import ( + "context" + "encoding/json" + "maps" + "time" + + "github.com/riverqueue/river" +) + +type EnqueueOption func(*river.InsertOpts) + +func WithMetadata(ctx context.Context, md JobMetadata) EnqueueOption { + return func(o *river.InsertOpts) { + existingMD, err := parseMetadata(o.Metadata) + if err != nil { + existingMD = make(JobMetadata) + } + + maps.Copy(existingMD, md) + metadata, err := json.Marshal(existingMD) + if err != nil { + return + } + + o.Metadata = metadata + } +} + +func WithSchedule(schedule time.Time) EnqueueOption { + return func(opts *river.InsertOpts) { + opts.ScheduledAt = schedule + } +} + +func WithMaxAttempts(attempts int) EnqueueOption { + return func(opts *river.InsertOpts) { + opts.MaxAttempts = attempts + } +} + +func WithPriority(priority int) EnqueueOption { + return func(opts *river.InsertOpts) { + opts.Priority = priority + } +} diff --git a/app/internal/worker/pool.go b/app/internal/worker/pool.go new file mode 100644 index 0000000..eb50a23 --- /dev/null +++ b/app/internal/worker/pool.go @@ -0,0 +1,133 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "io" + stdslog "log/slog" + + "github.com/alexliesenfeld/health" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivertype" + + "github.com/jace-ys/countup/internal/app" + "github.com/jace-ys/countup/internal/healthz" + "github.com/jace-ys/countup/internal/slog" +) + +func Register[T river.JobArgs](pool *Pool, worker river.Worker[T]) { + river.AddWorker(pool.workers, worker) +} + +type Pool struct { + name string + pool *river.Client[pgx.Tx] + workers *river.Workers + + metrics *metrics +} + +func NewPool(ctx context.Context, name string, db *pgxpool.Pool, w io.Writer, concurrency int) (*Pool, error) { + metrics, err := newMetrics() + if err != nil { + return nil, fmt.Errorf("init metrics: %w", err) + } + + workers := river.NewWorkers() + + client, err := river.NewClient(riverpgxv5.New(db), &river.Config{ + Workers: workers, + Queues: map[string]river.QueueConfig{ + river.QueueDefault: {MaxWorkers: concurrency}, + }, + JobInsertMiddleware: []rivertype.JobInsertMiddleware{ + &instrumentedJobInsertMiddleware{metrics: metrics}, + }, + WorkerMiddleware: []rivertype.WorkerMiddleware{ + &instrumentedWorkerMiddleware{}, + }, + Logger: stdslog.New(slog.AsNopHandler()), + }) + if err != nil { + return nil, fmt.Errorf("init river client: %w", err) + } + + return &Pool{ + name: name, + pool: client, + workers: workers, + metrics: metrics, + }, nil +} + +func (p *Pool) Enqueue(ctx context.Context, job river.JobArgs, opts ...EnqueueOption) error { + opts = append(opts, withContextMetadata(ctx)) + + insertOpts := &river.InsertOpts{} + for _, opt := range opts { + opt(insertOpts) + } + + _, err := p.pool.Insert(ctx, job, insertOpts) + return err //nolint:wrapcheck +} + +func (p *Pool) EnqueueTx(ctx context.Context, tx pgx.Tx, job river.JobArgs, opts ...EnqueueOption) error { + opts = append(opts, withContextMetadata(ctx)) + + insertOpts := &river.InsertOpts{} + for _, opt := range opts { + opt(insertOpts) + } + + _, err := p.pool.InsertTx(ctx, tx, job, insertOpts) + return err //nolint:wrapcheck +} + +var _ app.Server = (*Pool)(nil) + +func (p *Pool) Name() string { + return p.name +} + +func (p *Pool) Kind() string { + return "worker" +} + +func (p *Pool) Addr() string { + return "" +} + +func (p *Pool) Serve(ctx context.Context) error { + go p.runMetricsExporter(ctx) + if err := p.pool.Start(ctx); err != nil { + return fmt.Errorf("starting worker pool: %w", err) + } + return nil +} + +func (p *Pool) Shutdown(ctx context.Context) error { + return p.pool.StopAndCancel(ctx) //nolint:wrapcheck +} + +var _ healthz.Target = (*Pool)(nil) + +func (p *Pool) HealthChecks() []health.Check { + return []health.Check{ + { + Name: fmt.Sprintf("%s:%s", p.Kind(), p.Name()), + Check: func(ctx context.Context) error { + select { + case <-p.pool.Stopped(): + return errors.New("river client reported as not running") + default: + return nil + } + }, + }, + } +} diff --git a/app/schema/counter.sql b/app/schema/counter.sql new file mode 100644 index 0000000..5170922 --- /dev/null +++ b/app/schema/counter.sql @@ -0,0 +1,46 @@ +-- name: GetCounter :one +SELECT * +FROM counter +WHERE id = 1; + +-- name: IncrementCounter :exec +UPDATE counter +SET + count = count + 1, + last_increment_by = $1, + last_increment_at = $2, + next_finalize_at = NULL +WHERE id = 1; + +-- name: ResetCounter :exec +UPDATE counter +SET + count = 0, + last_increment_by = NULL, + last_increment_at = NULL, + next_finalize_at = NULL +WHERE id = 1; + +-- name: UpdateCounterFinalizeTime :exec +UPDATE counter +SET + next_finalize_at = $1 +WHERE id = 1; + +-- name: ListIncrementRequests :many +SELECT * +FROM increment_requests; + +-- name: InsertIncrementRequest :one +WITH inserted AS ( + INSERT INTO increment_requests (requested_by, requested_at) + VALUES ($1, $2) +), existing AS ( + SELECT COUNT(*) AS requests FROM increment_requests +) +SELECT + existing.requests AS existing_requests +FROM existing; + +-- name: TruncateIncrementRequests :exec +TRUNCATE TABLE increment_requests; \ No newline at end of file diff --git a/app/schema/migrations/20241003194615_rivermigrate002.go b/app/schema/migrations/20241003194615_rivermigrate002.go new file mode 100644 index 0000000..738685c --- /dev/null +++ b/app/schema/migrations/20241003194615_rivermigrate002.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/rivermigrate" +) + +func init() { + goose.AddMigrationContext(upRiverMigrate002, downRiverMigrate002) +} + +func upRiverMigrate002(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{ + TargetVersion: 2, + }) + return err +} + +func downRiverMigrate002(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ + TargetVersion: -1, + }) + return err +} diff --git a/app/schema/migrations/20241003194729_rivermigrate003.go b/app/schema/migrations/20241003194729_rivermigrate003.go new file mode 100644 index 0000000..ab5875d --- /dev/null +++ b/app/schema/migrations/20241003194729_rivermigrate003.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/rivermigrate" +) + +func init() { + goose.AddMigrationContext(upRiverMigrate003, downRiverMigrate003) +} + +func upRiverMigrate003(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{ + TargetVersion: 3, + }) + return err +} + +func downRiverMigrate003(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ + TargetVersion: 2, + }) + return err +} diff --git a/app/schema/migrations/20241003195032_rivermigrate004.go b/app/schema/migrations/20241003195032_rivermigrate004.go new file mode 100644 index 0000000..3505b29 --- /dev/null +++ b/app/schema/migrations/20241003195032_rivermigrate004.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/rivermigrate" +) + +func init() { + goose.AddMigrationContext(upRiverMigrate004, downRiverMigrate004) +} + +func upRiverMigrate004(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{ + TargetVersion: 4, + }) + return err +} + +func downRiverMigrate004(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ + TargetVersion: 3, + }) + return err +} diff --git a/app/schema/migrations/20241003195147_rivermigrate005.go b/app/schema/migrations/20241003195147_rivermigrate005.go new file mode 100644 index 0000000..485decc --- /dev/null +++ b/app/schema/migrations/20241003195147_rivermigrate005.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/rivermigrate" +) + +func init() { + goose.AddMigrationContext(upRiverMigrate005, downRiverMigrate005) +} + +func upRiverMigrate005(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{ + TargetVersion: 5, + }) + return err +} + +func downRiverMigrate005(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ + TargetVersion: 4, + }) + return err +} diff --git a/app/schema/migrations/20241003195218_rivermigrate006.go b/app/schema/migrations/20241003195218_rivermigrate006.go new file mode 100644 index 0000000..7302f20 --- /dev/null +++ b/app/schema/migrations/20241003195218_rivermigrate006.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" + "github.com/riverqueue/river/rivermigrate" +) + +func init() { + goose.AddMigrationContext(upRiverMigrate006, downRiverMigrate006) +} + +func upRiverMigrate006(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{ + TargetVersion: 6, + }) + return err +} + +func downRiverMigrate006(ctx context.Context, tx *sql.Tx) error { + _, err := rivermigrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ + TargetVersion: 5, + }) + return err +} diff --git a/app/schema/migrations/20241005193820_create_table_counter.sql b/app/schema/migrations/20241005193820_create_table_counter.sql new file mode 100644 index 0000000..5306492 --- /dev/null +++ b/app/schema/migrations/20241005193820_create_table_counter.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- create "counter" table +CREATE TABLE "public"."counter" ("id" serial NOT NULL, "count" integer NOT NULL, "last_increment_by" text NULL, "last_increment_at" timestamptz NULL, "next_finalize_at" timestamptz NULL, PRIMARY KEY ("id"), CONSTRAINT "counter_id_check" CHECK (id = 1)); + +-- +goose Down +-- reverse: create "counter" table +DROP TABLE "public"."counter"; diff --git a/app/schema/migrations/20241005201313_init_table_counter.sql b/app/schema/migrations/20241005201313_init_table_counter.sql new file mode 100644 index 0000000..b00a7e6 --- /dev/null +++ b/app/schema/migrations/20241005201313_init_table_counter.sql @@ -0,0 +1,5 @@ +-- +goose Up +INSERT INTO counter (id, count) VALUES (1, 0); + +-- +goose Down +DELETE FROM counter WHERE id = 1; diff --git a/app/schema/migrations/20241006142909_create_table_increment_requests.sql b/app/schema/migrations/20241006142909_create_table_increment_requests.sql new file mode 100644 index 0000000..c3c2bdc --- /dev/null +++ b/app/schema/migrations/20241006142909_create_table_increment_requests.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- create "increment_requests" table +CREATE TABLE "public"."increment_requests" ("requested_by" text NOT NULL, "requested_at" timestamptz NOT NULL); + +-- +goose Down +-- reverse: create "increment_requests" table +DROP TABLE "public"."increment_requests"; diff --git a/app/schema/migrations/20241008214418_increment_requests_requested_by_unique.sql b/app/schema/migrations/20241008214418_increment_requests_requested_by_unique.sql new file mode 100644 index 0000000..c02a736 --- /dev/null +++ b/app/schema/migrations/20241008214418_increment_requests_requested_by_unique.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- modify "increment_requests" table +ALTER TABLE "public"."increment_requests" ADD CONSTRAINT "increment_requests_requested_by_key" UNIQUE ("requested_by"); + +-- +goose Down +-- reverse: modify "increment_requests" table +ALTER TABLE "public"."increment_requests" DROP CONSTRAINT "increment_requests_requested_by_key"; diff --git a/app/schema/migrations/atlas.sum b/app/schema/migrations/atlas.sum new file mode 100644 index 0000000..19ce625 --- /dev/null +++ b/app/schema/migrations/atlas.sum @@ -0,0 +1,5 @@ +h1:9gV77Wmax6qqAAnTlbE0f4FEsxfHIt9RySYga3UwBYk= +20241005193820_create_table_counter.sql h1:Ui0s/qnIp8ibNveOPX8JtlTJbeN50N7/i51oiwV12AA= +20241005201313_init_table_counter.sql h1:CUxofZ7Xm6jz6uJnYboeazrISEgYJR2DLNqhx0J8ftM= +20241006142909_create_table_increment_requests.sql h1:/HurEk0XNVHafHi7GkurUW0F0fI8K0XzMOKsSSlu6Wo= +20241008214418_increment_requests_requested_by_unique.sql h1:awnLFBm+7S1+NitfcMjZwAt+78Xvq8Lk14+FhJQP0+4= diff --git a/app/schema/migrations/embed.go b/app/schema/migrations/embed.go new file mode 100644 index 0000000..a46c69c --- /dev/null +++ b/app/schema/migrations/embed.go @@ -0,0 +1,8 @@ +package migrations + +import ( + "embed" +) + +//go:embed *.sql +var FSDir embed.FS diff --git a/app/schema/migrations/rivermigrate.go b/app/schema/migrations/rivermigrate.go new file mode 100644 index 0000000..2da4562 --- /dev/null +++ b/app/schema/migrations/rivermigrate.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" +) + +var rivermigrator *rivermigrate.Migrator[pgx.Tx] + +func WithRiverMigrate(db *pgxpool.Pool) error { + migrator, err := rivermigrate.New(riverpgxv5.New(db), &rivermigrate.Config{}) + if err != nil { + return fmt.Errorf("init river migrator: %w", err) + } + + rivermigrator = migrator + return nil +} diff --git a/app/schema/schema.sql b/app/schema/schema.sql new file mode 100644 index 0000000..cb569c9 --- /dev/null +++ b/app/schema/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE counter ( + id SERIAL PRIMARY KEY CHECK (id = 1), + count INTEGER NOT NULL, + last_increment_by TEXT, + last_increment_at TIMESTAMPTZ, + next_finalize_at TIMESTAMPTZ +); + +CREATE UNLOGGED TABLE increment_requests ( + requested_by TEXT NOT NULL UNIQUE, + requested_at TIMESTAMPTZ NOT NULL +); \ No newline at end of file diff --git a/app/schema/users.sql b/app/schema/users.sql new file mode 100644 index 0000000..e69de29 diff --git a/app/sqlc.yaml b/app/sqlc.yaml new file mode 100644 index 0000000..0d1cf03 --- /dev/null +++ b/app/sqlc.yaml @@ -0,0 +1,15 @@ +--- +version: "2" + +sql: +- name: counterstore + engine: postgresql + queries: schema/counter.sql + schema: schema/schema.sql + gen: + go: + package: counterstore + out: internal/service/counter/store + sql_package: pgx/v5 + emit_interface: true + emit_methods_with_db_argument: true diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7c089f2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,202 @@ +name: countup + +services: + countup: + image: jace-ys/countup:0.0.0 + build: + context: ./app + profiles: [apps] + labels: + service: countup + tier: app + environment: dev + ports: + - 8080:8080 + - 8081:8081 + - 9090:9090 + command: + - server + depends_on: + postgres: + condition: service_healthy + postgres-init: + condition: service_completed_successfully + otel-collector: + condition: service_started + environment: + OTEL_GO_X_EXEMPLAR: true + OTEL_RESOURCE_ATTRIBUTES: tier=app,environment=dev + OTLP_METRICS_ENDPOINT: otel-collector:4317 + OTLP_TRACES_ENDPOINT: otel-collector:4317 + DATABASE_CONNECTION_URI: postgresql://countup:countup@postgres:5432/countup + + postgres: + image: postgres:15.8-alpine + labels: + service: postgres + component: primary + tier: database + environment: dev + ports: + - 5432:5432 + environment: + POSTGRES_USER: countup + POSTGRES_PASSWORD: countup + POSTGRES_DB: countup + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + retries: 3 + start_period: 10s + timeout: 5s + + postgres-init: + image: jace-ys/countup:0.0.0 + build: + context: ./app + labels: + service: postgres + component: init + tier: database + environment: dev + command: + - migrate + - up + depends_on: + postgres: + condition: service_healthy + otel-collector: + condition: service_started + environment: + OTLP_METRICS_ENDPOINT: otel-collector:4317 + OTLP_TRACES_ENDPOINT: otel-collector:4317 + DATABASE_CONNECTION_URI: postgresql://countup:countup@postgres:5432/countup + MIGRATIONS_DIR: /app/migrations + MIGRATIONS_LOCALFS: true + volumes: + - ./app/schema/migrations:/app/migrations + + postgres-exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0 + labels: + service: postgres + component: exporter + tier: database + environment: dev + ports: + - 9187:9187 + depends_on: + postgres: + condition: service_healthy + environment: + DATA_SOURCE_URI: postgres:5432/countup?sslmode=disable + DATA_SOURCE_USER: countup + DATA_SOURCE_PASS: countup + + grafana: + image: grafana/grafana:11.1.4 + labels: + service: grafana + tier: monitoring + environment: dev + ports: + - 3000:3000 + command: + - --config=/etc/grafana/config.ini + volumes: + - ./infra/environments/dev/grafana/config.ini:/etc/grafana/config.ini + - ./infra/environments/dev/grafana/provisioning:/etc/grafana/provisioning + - ./infra/environments/dev/grafana/definitions:/var/lib/grafana/dashboards + environment: + - GF_INSTALL_PLUGINS=https://storage.googleapis.com/integration-artifacts/grafana-lokiexplore-app/grafana-lokiexplore-app-latest.zip;grafana-lokiexplore-app + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.107.0 + labels: + service: otel-collector + tier: monitoring + environment: dev + user: '0' + ports: + - 4317:4317 + - 8888:8888 + command: + - --config=/etc/otel-collector/config.yaml + depends_on: + mimir: + condition: service_started + tempo: + condition: service_started + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./infra/environments/dev/otel-collector/config.yaml:/etc/otel-collector/config.yaml + + promtail: + image: grafana/promtail:3.1.1 + labels: + service: promtail + tier: monitoring + environment: dev + ports: + - 3080:3080 + command: + - -config.file=/etc/promtail/config.yaml + depends_on: + loki: + condition: service_started + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./infra/environments/dev/promtail/config.yaml:/etc/promtail/config.yaml + + loki: + image: grafana/loki:3.1.1 + labels: + service: loki + tier: monitoring + environment: dev + command: + - -config.file=/etc/loki/config.yaml + ports: + - 3100:3100 + volumes: + - ./infra/environments/dev/loki/config.yaml:/etc/loki/config.yaml + + tempo: + image: grafana/tempo:2.5.0 + labels: + service: tempo + tier: monitoring + environment: dev + ports: + - 3200:3200 + command: + - -config.file=/etc/tempo/config.yaml + volumes: + - ./infra/environments/dev/tempo/config.yaml:/etc/tempo/config.yaml + + mimir: + image: grafana/mimir:2.13.0 + labels: + service: mimir + tier: monitoring + environment: dev + command: + - -ingester.native-histograms-ingestion-enabled=true + - -config.file=/etc/mimir/config.yaml + ports: + - 3300:3300 + volumes: + - ./infra/environments/dev/mimir/config.yaml:/etc/mimir/config.yaml + + swagger: + image: swaggerapi/swagger-ui:v5.17.14 + labels: + service: swagger + tier: devtool + environment: dev + ports: + - 5000:8080 + volumes: + - ./app/api/v1/gen/http/openapi3.json:/etc/swagger/openapi3.json + environment: + SWAGGER_JSON: /etc/swagger/openapi3.json \ No newline at end of file diff --git a/digger.yml b/digger.yml new file mode 100644 index 0000000..fd580dd --- /dev/null +++ b/digger.yml @@ -0,0 +1,8 @@ +--- +telemetry: false +traverse_to_nested_projects: true + +projects: +- name: dev + dir: infra/environments/dev/gcp + workspace: dev diff --git a/infra/environments/dev/compose/grafana/config.ini b/infra/environments/dev/compose/grafana/config.ini new file mode 100644 index 0000000..011c225 --- /dev/null +++ b/infra/environments/dev/compose/grafana/config.ini @@ -0,0 +1,14 @@ +http_port = 3000 + +[log] +level = warn + +[log.console] +format = json + +[auth] +disable_login_form = true + +[auth.anonymous] +enabled = true +org_role = Admin \ No newline at end of file diff --git a/infra/environments/dev/compose/grafana/definitions/otel-collector.json b/infra/environments/dev/compose/grafana/definitions/otel-collector.json new file mode 100644 index 0000000..376ab9e --- /dev/null +++ b/infra/environments/dev/compose/grafana/definitions/otel-collector.json @@ -0,0 +1,4006 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "description": "Visualize OpenTelemetry (OTEL) collector metrics (tested with OTEL contrib v0.101.0)", + "editable": true, + "fiscalYearStartMonth": 0, + "gnetId": 15983, + "graphTooltip": 1, + "id": 2, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 23, + "panels": [], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "refId": "A" + } + ], + "title": "Receivers", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of spans successfully pushed into the pipeline.\nRefused: count/rate of spans that could not be pushed into the pipeline.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 28, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_accepted_spans${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_refused_spans${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Spans ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of metric points successfully pushed into the pipeline.\nRefused: count/rate of metric points that could not be pushed into the pipeline.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 32, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_accepted_metric_points${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_refused_metric_points${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Metric Points ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of log records successfully pushed into the pipeline.\nRefused: count/rate of log records that could not be pushed into the pipeline.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 47, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_accepted_log_records${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_receiver_refused_log_records${suffix}{receiver=~\"$receiver\",job=\"$job\"}[$__rate_interval])) by (receiver $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{receiver}} {{transport}} {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Log Records ${metric:text}", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 34, + "panels": [], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "refId": "A" + } + ], + "title": "Processors", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of spans successfully pushed into the next component in the pipeline.\nRefused: count/rate of spans that were rejected by the next component in the pipeline.\nDropped: count/rate of spans that were dropped", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/Dropped.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 35, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_accepted_spans${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_refused_spans${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_dropped_spans${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Dropped: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Spans ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of metric points successfully pushed into the next component in the pipeline.\nRefused: count/rate of metric points that were rejected by the next component in the pipeline.\nDropped: count/rate of metric points that were dropped", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/Dropped.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 50, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_accepted_metric_points${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_refused_metric_points${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_dropped_metric_points${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Dropped: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Metric Points ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Accepted: count/rate of log records successfully pushed into the next component in the pipeline.\nRefused: count/rate of log records that were rejected by the next component in the pipeline.\nDropped: count/rate of log records that were dropped", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/Dropped.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 51, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_accepted_log_records${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Accepted: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_refused_log_records${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Refused: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_dropped_log_records${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Dropped: {{processor}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Log Records ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Number of units in the batch", + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + }, + "links": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 49, + "interval": "$minstep", + "maxDataPoints": 50, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": true, + "scale": "exponential", + "scheme": "Reds", + "steps": 57 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(increase(otelcol_processor_batch_batch_send_size_bucket{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "hide": false, + "instant": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "{{le}}", + "refId": "B" + } + ], + "title": "Batch Send Size Heatmap", + "type": "heatmap" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/.*Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/.*Dropped.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 36, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_batch_batch_send_size_count{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "instant": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Batch send size count: {{processor}} {{service_instance_id}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_batch_batch_send_size_sum{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "instant": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Batch send size sum: {{processor}} {{service_instance_id}}", + "refId": "A" + } + ], + "title": "Batch Metrics 1", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Number of times the batch was sent due to a size trigger. Number of times the batch was sent due to a timeout trigger.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/.*Refused.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/.*Dropped.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 56, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_batch_batch_size_trigger_send${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "instant": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Batch sent due to a size trigger: {{processor}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_processor_batch_timeout_trigger_send${suffix}{processor=~\"$processor\",job=\"$job\"}[$__rate_interval])) by (processor $grouping)", + "format": "time_series", + "hide": false, + "instant": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Batch sent due to a timeout trigger: {{processor}} {{service_instance_id}}", + "refId": "A" + } + ], + "title": "Batch Metrics 2", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 25, + "panels": [], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "refId": "A" + } + ], + "title": "Exporters", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Sent: count/rate of spans successfully sent to destination.\nEnqueue: count/rate of spans failed to be added to the sending queue.\nFailed: count/rate of spans in failed attempts to send to destination.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Failed:.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 27 + }, + "id": 37, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_sent_spans${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Sent: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_enqueue_failed_spans${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Enqueue: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_send_failed_spans${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Failed: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Spans ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Sent: count/rate of metric points successfully sent to destination.\nEnqueue: count/rate of metric points failed to be added to the sending queue.\nFailed: count/rate of metric points in failed attempts to send to destination.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Failed:.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 27 + }, + "id": 38, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_sent_metric_points${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Sent: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_enqueue_failed_metric_points${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Enqueue: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_send_failed_metric_points${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Failed: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Metric Points ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Sent: count/rate of log records successfully sent to destination.\nEnqueue: count/rate of log records failed to be added to the sending queue.\nFailed: count/rate of log records in failed attempts to send to destination.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/Failed:.*/" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 27 + }, + "id": 48, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_sent_log_records${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Sent: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_enqueue_failed_log_records${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Enqueue: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(${metric:value}(otelcol_exporter_send_failed_log_records${suffix}{exporter=~\"$exporter\",job=\"$job\"}[$__rate_interval])) by (exporter $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Failed: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "Log Records ${metric:text}", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Current size of the retry queue (in batches)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 36 + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(otelcol_exporter_queue_size{exporter=~\"$exporter\",job=\"$job\"}) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Max queue size: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + } + ], + "title": "Exporter Queue Size", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Fixed capacity of the retry queue (in batches)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 36 + }, + "id": 55, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "min(otelcol_exporter_queue_capacity{exporter=~\"$exporter\",job=\"$job\"}) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Queue capacity: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + } + ], + "title": "Exporter Queue Capacity", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 36 + }, + "id": 67, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(\r\n otelcol_exporter_queue_size{\r\n exporter=~\"$exporter\", job=\"$job\"\r\n }\r\n) by (exporter $grouping)\r\n/\r\nmin(\r\n otelcol_exporter_queue_capacity{\r\n exporter=~\"$exporter\", job=\"$job\"\r\n }\r\n) by (exporter $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Queue capacity usage: {{exporter}} {{service_instance_id}}", + "range": true, + "refId": "A" + } + ], + "title": "Exporter Queue Usage", + "type": "timeseries" + }, + { + "collapsed": false, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 45 + }, + "id": 21, + "panels": [], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "refId": "A" + } + ], + "title": "Collector", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Total physical memory (resident set size)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Max Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Avg Memory RSS " + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + }, + { + "id": "custom.lineWidth", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Avg Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Min Memory RSS " + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 46 + }, + "id": 40, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(otelcol_process_memory_rss{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Max Memory RSS {{service_instance_id}}", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "avg(otelcol_process_memory_rss{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Avg Memory RSS {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "min(otelcol_process_memory_rss{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Min Memory RSS {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Total RSS Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Total bytes of memory obtained from the OS (see 'go doc runtime.MemStats.Sys')", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Max Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Avg Memory RSS " + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + }, + { + "id": "custom.lineWidth", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Avg Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Min Memory RSS " + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 46 + }, + "id": 52, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(otelcol_process_runtime_total_sys_memory_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Max Memory RSS {{service_instance_id}}", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "avg(otelcol_process_runtime_total_sys_memory_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Avg Memory RSS {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "min(otelcol_process_runtime_total_sys_memory_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Min Memory RSS {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Total Runtime Sys Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Bytes of allocated heap objects (see 'go doc runtime.MemStats.HeapAlloc')", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Max Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Avg Memory RSS " + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + }, + { + "id": "custom.lineWidth", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Avg Memory RSS " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Min Memory RSS " + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 46 + }, + "id": 53, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(otelcol_process_runtime_heap_alloc_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Max Memory RSS {{service_instance_id}}", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "avg(otelcol_process_runtime_heap_alloc_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Avg Memory RSS {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "min(otelcol_process_runtime_heap_alloc_bytes{job=\"$job\"}) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Min Memory RSS {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Total Runtime Heap Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Total CPU user and system time in percentage", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Max CPU usage " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Avg CPU usage " + }, + { + "id": "custom.lineWidth", + "value": 0 + }, + { + "id": "custom.fillOpacity", + "value": 20 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Avg CPU usage " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + }, + { + "id": "custom.fillBelowTo", + "value": "Min CPU usage " + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min CPU usage " + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + }, + { + "id": "custom.lineWidth", + "value": 0 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 55 + }, + "id": 39, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(rate(otelcol_process_cpu_seconds${suffix}{job=\"$job\"}[$__rate_interval])*100) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Max CPU usage {{service_instance_id}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "avg(rate(otelcol_process_cpu_seconds${suffix}{job=\"$job\"}[$__rate_interval])*100) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Avg CPU usage {{service_instance_id}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "min(rate(otelcol_process_cpu_seconds${suffix}{job=\"$job\"}[$__rate_interval])*100) by (job $grouping)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Min CPU usage {{service_instance_id}}", + "range": true, + "refId": "C" + } + ], + "title": "CPU Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Number of service instances, which are reporting metrics", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 55 + }, + "id": 41, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "count(count(otelcol_process_cpu_seconds${suffix}{service_instance_id=~\".*\",job=\"$job\"}) by (service_instance_id))", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Service instance count", + "range": true, + "refId": "B" + } + ], + "title": "Service Instance Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 55 + }, + "id": 54, + "interval": "$minstep", + "options": { + "legend": { + "calcs": [ + "min", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "8.3.5", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": true, + "expr": "max(otelcol_process_uptime${suffix}{service_instance_id=~\".*\",job=\"$job\"}) by (service_instance_id)", + "format": "time_series", + "hide": false, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "Service instance uptime: {{service_instance_id}}", + "range": true, + "refId": "B" + } + ], + "title": "Uptime by Service Instance", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 64 + }, + "id": 57, + "interval": "$minstep", + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max(otelcol_process_uptime${suffix}{service_instance_id=~\".*\",job=\"$job\"}) by (service_instance_id,service_name,service_version)", + "format": "table", + "hide": false, + "instant": true, + "interval": "$minstep", + "intervalFactor": 1, + "legendFormat": "__auto", + "range": false, + "refId": "B" + } + ], + "title": "Service Instance Details", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true + }, + "indexByName": {}, + "renameByName": {} + } + } + ], + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 69 + }, + "id": 59, + "panels": [], + "title": "Signal Flows", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Receivers -> Processor(s) -> Exporters (Node Graph panel is beta, so this panel may not show data correctly).", + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 70 + }, + "id": 58, + "options": { + "edges": {}, + "nodes": { + "mainStatUnit": "flops" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers\nlabel_replace(\n label_join(\n label_join(\n sum(${metric:value}(\n otelcol_receiver_accepted_spans${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (receiver)\n , \"id\", \"-rcv-\", \"transport\", \"receiver\"\n )\n , \"title\", \"\", \"transport\", \"receiver\"\n )\n , \"icon\", \"arrow-to-right\", \"\", \"\"\n)\n\n# dummy processor\nor\nlabel_replace(\n label_replace(\n label_replace(\n (sum(rate(otelcol_process_uptime${suffix}{job=\"$job\"}[$__rate_interval])))\n , \"id\", \"processor\", \"\", \"\"\n )\n , \"title\", \"Processor(s)\", \"\", \"\"\n )\n , \"icon\", \"arrow-random\", \"\", \"\"\n)\n\n# exporters\nor\nlabel_replace(\n label_join(\n label_join(\n sum(${metric:value}(\n otelcol_exporter_sent_spans${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (exporter)\n , \"id\", \"-exp-\", \"transport\", \"exporter\"\n )\n , \"title\", \"\", \"transport\", \"exporter\"\n )\n , \"icon\", \"arrow-from-right\", \"\", \"\"\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "nodes" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers -> processor\r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_receiver_accepted_spans${suffix}{job=\"$job\"}[$__rate_interval])) by (receiver))\r\n ,\"source\", \"-rcv-\", \"transport\", \"receiver\"\r\n )\r\n ,\"target\", \"processor\", \"\", \"\"\r\n )\r\n , \"id\", \"-\", \"source\", \"target\"\r\n)\r\n\r\n# processor -> exporters\r\nor\r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_exporter_sent_spans${suffix}{job=\"$job\"}[$__rate_interval])) by (exporter))\r\n , \"target\", \"-exp-\", \"transport\", \"exporter\"\r\n )\r\n , \"source\", \"processor\", \"\", \"\"\r\n )\r\n , \"id\", \"-\", \"source\", \"target\"\r\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "edges" + } + ], + "title": "Spans Flow", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "Value", + "renamePattern": "mainstat" + } + }, + { + "disabled": true, + "id": "calculateField", + "options": { + "alias": "secondarystat", + "mode": "reduceRow", + "reduce": { + "include": [ + "mainstat" + ], + "reducer": "sum" + } + } + } + ], + "type": "nodeGraph" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Receivers -> Processor(s) -> Exporters (Node Graph panel is beta, so this panel may not show data correctly).", + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 70 + }, + "id": 60, + "options": { + "edges": {}, + "nodes": { + "mainStatUnit": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers\nlabel_replace(\n label_join(\n label_join(\n (sum(\n ${metric:value}(otelcol_receiver_accepted_metric_points${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (receiver))\n , \"id\", \"-rcv-\", \"transport\", \"receiver\"\n )\n , \"title\", \"\", \"transport\", \"receiver\"\n )\n , \"icon\", \"arrow-to-right\", \"\", \"\"\n)\n\n# dummy processor\nor\nlabel_replace(\n label_replace(\n label_replace(\n (sum(rate(otelcol_process_uptime${suffix}{job=\"$job\"}[$__rate_interval])))\n , \"id\", \"processor\", \"\", \"\"\n )\n , \"title\", \"Processor(s)\", \"\", \"\"\n )\n , \"icon\", \"arrow-random\", \"\", \"\"\n)\n\n# exporters\nor\nlabel_replace(\n label_join(\n label_join(\n (sum(\n ${metric:value}(otelcol_exporter_sent_metric_points${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (exporter))\n , \"id\", \"-exp-\", \"transport\", \"exporter\"\n )\n , \"title\", \"\", \"transport\", \"exporter\"\n )\n , \"icon\", \"arrow-from-right\", \"\", \"\"\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "nodes" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers -> processor\r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_receiver_accepted_metric_points${suffix}{job=\"$job\"}[$__rate_interval])) by (receiver))\r\n , \"source\", \"-rcv-\", \"transport\", \"receiver\"\r\n )\r\n , \"target\", \"processor\", \"\", \"\"\r\n )\r\n , \"id\", \"-\", \"source\", \"target\"\r\n)\r\n\r\n# processor -> exporters\r\nor \r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_exporter_sent_metric_points${suffix}{job=\"$job\"}[$__rate_interval])) by (exporter))\r\n , \"target\", \"-exp-\", \"transport\", \"exporter\"\r\n )\r\n , \"source\", \"processor\", \"\", \"\"\r\n )\r\n , \"id\", \"-\", \"source\", \"target\"\r\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "edges" + } + ], + "title": "Metric Points Flow", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "Value", + "renamePattern": "mainstat" + } + }, + { + "disabled": true, + "id": "calculateField", + "options": { + "alias": "secondarystat", + "mode": "reduceRow", + "reduce": { + "include": [ + "Value #nodes" + ], + "reducer": "sum" + } + } + } + ], + "type": "nodeGraph" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Receivers -> Processor(s) -> Exporters (Node Graph panel is beta, so this panel may not show data correctly).", + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 70 + }, + "id": 61, + "options": { + "edges": {}, + "nodes": { + "mainStatUnit": "flops" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers\nlabel_replace(\n label_join(\n label_join(\n sum(${metric:value}(\n otelcol_receiver_accepted_log_records${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (receiver)\n , \"id\", \"-rcv-\", \"transport\", \"receiver\"\n )\n , \"title\", \"\", \"transport\", \"receiver\"\n )\n , \"icon\", \"arrow-to-right\", \"\", \"\"\n)\n\n# dummy processor\nor\nlabel_replace(\n label_replace(\n label_replace(\n (sum(rate(otelcol_process_uptime${suffix}{job=\"$job\"}[$__rate_interval])))\n , \"id\", \"processor\", \"\", \"\"\n )\n , \"title\", \"Processor(s)\", \"\", \"\"\n )\n , \"icon\", \"arrow-random\", \"\", \"\"\n)\n\n# exporters\nor\nlabel_replace(\n label_join(\n label_join(\n sum(${metric:value}(\n otelcol_exporter_sent_log_records${suffix}{job=\"$job\"}[$__rate_interval])\n ) by (exporter)\n , \"id\", \"-exp-\", \"transport\", \"exporter\"\n )\n , \"title\", \"\", \"transport\", \"exporter\"\n )\n , \"icon\", \"arrow-from-right\", \"\", \"\"\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "nodes" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "editorMode": "code", + "exemplar": false, + "expr": "# receivers -> processor\r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_receiver_accepted_log_records${suffix}{job=\"$job\"}[$__rate_interval])) by (receiver))\r\n , \"source\", \"-rcv-\", \"transport\", \"receiver\"\r\n )\r\n , \"target\", \"processor\", \"\", \"\"\r\n )\r\n , \"id\", \"-edg-\", \"source\", \"target\"\r\n)\r\n\r\n# processor -> exporters\r\nor \r\nlabel_join(\r\n label_replace(\r\n label_join(\r\n (sum(rate(otelcol_exporter_sent_log_records${suffix}{job=\"$job\"}[$__rate_interval])) by (exporter))\r\n ,\"target\",\"-exp-\",\"transport\",\"exporter\"\r\n )\r\n ,\"source\",\"processor\",\"\",\"\"\r\n )\r\n ,\"id\",\"-edg-\",\"source\",\"target\"\r\n)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "edges" + } + ], + "title": "Log Records Flow", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "Value", + "renamePattern": "mainstat" + } + }, + { + "disabled": true, + "id": "calculateField", + "options": { + "alias": "secondarystat", + "mode": "reduceRow", + "reduce": { + "include": [ + "mainstat" + ], + "reducer": "sum" + } + } + } + ], + "type": "nodeGraph" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Mimir", + "value": "mimir" + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": { + "selected": false, + "text": "otel-collector", + "value": "otel-collector" + }, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "definition": "label_values(otelcol_process_memory_rss,job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(otelcol_process_memory_rss,job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "auto": true, + "auto_count": 300, + "auto_min": "30s", + "current": { + "selected": true, + "text": "auto", + "value": "$__auto_interval_minstep" + }, + "hide": 0, + "label": "Min step", + "name": "minstep", + "options": [ + { + "selected": true, + "text": "auto", + "value": "$__auto_interval_minstep" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + } + ], + "query": "10s,30s,1m,5m", + "queryValue": "", + "refresh": 2, + "skipUrlSync": false, + "type": "interval" + }, + { + "current": { + "selected": true, + "text": "Rate", + "value": "rate" + }, + "hide": 0, + "includeAll": false, + "label": "Base metric", + "multi": false, + "name": "metric", + "options": [ + { + "selected": true, + "text": "Rate", + "value": "rate" + }, + { + "selected": false, + "text": "Count", + "value": "increase" + } + ], + "query": "Rate : rate, Count : increase", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "definition": "query_result(avg by (receiver) ({__name__=~\"otelcol_receiver_.+\",job=\"$job\"}))", + "hide": 0, + "includeAll": true, + "label": "Receiver", + "multi": false, + "name": "receiver", + "options": [], + "query": { + "qryType": 3, + "query": "query_result(avg by (receiver) ({__name__=~\"otelcol_receiver_.+\",job=\"$job\"}))", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "/.*receiver=\"(.*)\".*/", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "definition": "query_result(avg by (processor) ({__name__=~\"otelcol_processor_.+\",job=\"$job\"}))", + "hide": 0, + "includeAll": true, + "label": "Processor", + "multi": false, + "name": "processor", + "options": [], + "query": { + "qryType": 3, + "query": "query_result(avg by (processor) ({__name__=~\"otelcol_processor_.+\",job=\"$job\"}))", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "/.*processor=\"(.*)\".*/", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "definition": "query_result(avg by (exporter) ({__name__=~\"otelcol_exporter_.+\",job=\"$job\"}))", + "hide": 0, + "includeAll": true, + "label": "Exporter", + "multi": false, + "name": "exporter", + "options": [], + "query": { + "qryType": 3, + "query": "query_result(avg by (exporter) ({__name__=~\"otelcol_exporter_.+\",job=\"$job\"}))", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "/.*exporter=\"(.*)\".*/", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": { + "selected": true, + "text": "None (basic metrics)", + "value": "" + }, + "description": "Detailed metrics must be configured in the collector configuration. They add grouping by transport protocol (http/grpc) for receivers. ", + "hide": 0, + "includeAll": false, + "label": "Additional groupping", + "multi": false, + "name": "grouping", + "options": [ + { + "selected": true, + "text": "None (basic metrics)", + "value": "" + }, + { + "selected": false, + "text": "By transport (detailed metrics)", + "value": ",transport" + }, + { + "selected": false, + "text": "By service instance id", + "value": ",service_instance_id" + } + ], + "query": "None (basic metrics) : , By transport (detailed metrics) : \\,transport, By service instance id : \\,service_instance_id", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + }, + { + "current": { + "selected": false, + "text": "_total", + "value": "_total" + }, + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "definition": "query_result({__name__=~\"otelcol_process_uptime.+\",job=\"$job\"})", + "description": "Some newer prometheusremotewrite exporter versions/configurations add _total suffix, so this hidden variable detects if _total suffix should be used or not", + "hide": 2, + "includeAll": false, + "label": "Suffix", + "multi": false, + "name": "suffix", + "options": [], + "query": { + "qryType": 3, + "query": "query_result({__name__=~\"otelcol_process_uptime.+\",job=\"$job\"})", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "/otelcol_process_uptime(.*){.*/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "filters": [], + "hide": 0, + "label": "Ad Hoc", + "name": "adhoc", + "skipUrlSync": false, + "type": "adhoc" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "OpenTelemetry Collector", + "uid": "BKf2sowmj", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/infra/environments/dev/compose/grafana/definitions/service.json b/infra/environments/dev/compose/grafana/definitions/service.json new file mode 100644 index 0000000..ab89f15 --- /dev/null +++ b/infra/environments/dev/compose/grafana/definitions/service.json @@ -0,0 +1,4322 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 26, + "panels": [], + "title": "Go Runtime", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 27, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg(process_runtime_go_goroutines{service=\"$service\", environment=\"$environment\"})", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Goroutines", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ns" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 28, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(0.5, avg(rate(process_runtime_go_gc_pause_ns_bucket{service=\"$service\", environment=\"$environment\"}[$__rate_interval])) by (le))", + "legendFormat": "P50", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(0.99, avg(rate(process_runtime_go_gc_pause_ns_bucket{service=\"$service\", environment=\"$environment\"}[$__rate_interval])) by (le))", + "hide": false, + "legendFormat": "P99", + "range": true, + "refId": "B" + } + ], + "title": "GC Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg(process_runtime_go_mem_heap_alloc_bytes{service=\"$service\", environment=\"$environment\"})", + "legendFormat": "alloc", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg(process_runtime_go_mem_heap_inuse_bytes{service=\"$service\", environment=\"$environment\"})", + "hide": false, + "legendFormat": "in use", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg(process_runtime_go_mem_heap_idle_bytes{service=\"$service\", environment=\"$environment\"})", + "hide": false, + "legendFormat": "idle", + "range": true, + "refId": "C" + } + ], + "title": "Heap Bytes", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 41, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "never", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(go_panics_recovered_total{service=\"$service\", environment=\"$environment\"}[$__rate_interval]))", + "hide": false, + "legendFormat": "Rate", + "range": true, + "refId": "A" + } + ], + "title": "Recovered Panics", + "type": "status-history" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 8, + "panels": [], + "title": "Endpoints", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"goa.endpoint/.+\", goa_service=~\"$endpoint_service\", goa_method=~\"$endpoint_method\"}[$__rate_interval])) by (span_name)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Rates", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "shades" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"goa.endpoint/.+\", goa_service=~\"$endpoint_service\", goa_method=~\"$endpoint_method\", status_code=\"STATUS_CODE_ERROR\"}[$__rate_interval])) by (span_name)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Error Rates", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 25, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"goa.endpoint/.+\", goa_service=~\"$endpoint_service\", goa_method=~\"$endpoint_method\"}[$__rate_interval])) by (span_name, status_code, le))", + "legendFormat": "{{span_name}}: {{status_code}}", + "range": true, + "refId": "A" + } + ], + "title": "Request P95 Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 21, + "options": { + "edges": {}, + "nodes": {} + }, + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "limit": 20, + "query": "", + "queryType": "serviceMap", + "refId": "A", + "serviceMapQuery": "{}", + "tableType": "traces" + } + ], + "title": "Service Mesh", + "type": "nodeGraph" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 4, + "panels": [], + "title": "HTTP", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "right", + "cellOptions": { + "type": "color-text" + }, + "filterable": true, + "inspect": false + }, + "decimals": 0, + "fieldMinMax": false, + "mappings": [], + "max": 10, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "orange", + "value": 3000 + }, + { + "color": "red", + "value": 5000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "RPS" + }, + "properties": [ + { + "id": "unit", + "value": "reqps" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Requests" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Success %" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "#EAB839", + "value": 60 + }, + { + "color": "green", + "value": 90 + } + ] + } + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.cellOptions", + "value": { + "applyToRow": false, + "mode": "gradient", + "type": "color-background", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "HTTP Route" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "HTTP Method" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "auto" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 35 + }, + "id": 22, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (http_method, http_route) (rate(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "rps" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (http_method, http_route) (increase(http_server_duration_milliseconds_sum{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range])) / sum by (http_method, http_route) (increase(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "avg-response-time-seconds" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(.95, sum(rate(http_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range])) by (http_method, http_route, le))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (http_method, http_route) (increase(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "requests" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (http_method, http_route) (increase(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\", http_status_code=~\"2.*\"}[$__range])) / sum by (http_method, http_route) (increase(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__range])) * 100", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "success-rate" + } + ], + "title": "Overview", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "http_route", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time 1": true, + "Time 2": true, + "Time 3": true, + "Time 4": true, + "Time 5": true, + "http_method 2": true, + "http_method 3": true, + "http_method 4": true, + "http_method 5": true + }, + "includeByName": {}, + "indexByName": { + "Time 1": 2, + "Time 2": 4, + "Time 3": 7, + "Time 4": 10, + "Time 5": 13, + "Value #avg-response-time-seconds": 6, + "Value #p95": 9, + "Value #requests": 12, + "Value #rps": 3, + "Value #success-rate": 15, + "http_method 1": 0, + "http_method 2": 5, + "http_method 3": 8, + "http_method 4": 11, + "http_method 5": 14, + "http_route": 1 + }, + "renameByName": { + "Value #avg-response-time-seconds": "Average", + "Value #p95": "P95", + "Value #requests": "Requests", + "Value #rps": "RPS", + "Value #success-rate": "Success %", + "http_method 1": "HTTP Method", + "http_route": "HTTP Route" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 43 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__rate_interval])) by (http_method, http_route)", + "legendFormat": "{{http_method}} {{http_route}}", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "C" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 43 + }, + "id": 25, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\", http_status_code=~\"2..\"}[$__rate_interval])) by (http_method, http_route)", + "hide": false, + "legendFormat": "{{http_method}} {{http_route}} - 2XX", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\", http_status_code=~\"4..\"}[$__rate_interval])) by (http_method, http_route)", + "hide": false, + "legendFormat": "{{http_method}} {{http_route}} - 4XX", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(http_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\", http_status_code=~\"5..\"}[$__rate_interval])) by (http_method, http_route)", + "hide": false, + "legendFormat": "{{http_method}} {{http_route}} - 5XX", + "range": true, + "refId": "C" + } + ], + "title": "Response Status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(.95, sum(rate(http_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__rate_interval])) by (http_method, http_route, le))", + "legendFormat": "{{http_method}} {{http_route}}", + "range": true, + "refId": "A" + } + ], + "title": "P95 Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 39, + "maxDataPoints": 20, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "green", + "mode": "scheme", + "reverse": true, + "scale": "exponential", + "scheme": "Blues", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": false, + "showLegend": true + }, + "rowsFrame": { + "layout": "unknown" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "ms" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (le) (increase(http_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", http_method=~\"$http_method\", http_route=~\"$http_route\"}[$__interval]))", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Response Times", + "type": "heatmap" + }, + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 59 + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "hide": false, + "key": "Q-f807f3aa-fde3-4f3d-96e1-ebff01a76cb9-0", + "limit": 20, + "query": "{ resource.service.name = \"$service\" && resource.environment = \"$environment\" && span.http.method=~\"$http_method\" && span.http.route=~\"$http_route\" && status = error } || { resource.service.name = \"$service\" && resource.environment = \"$environment\" && span.http.method=~\"$http_method\" && span.http.route=~\"$http_route\" } >> { status = error }", + "queryType": "traceql", + "refId": "A", + "tableType": "traces" + } + ], + "title": "Error Traces", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 70 + }, + "id": 31, + "panels": [], + "title": "gRPC", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "right", + "cellOptions": { + "type": "color-text" + }, + "filterable": true, + "inspect": false + }, + "decimals": 0, + "fieldMinMax": false, + "mappings": [], + "max": 10, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "orange", + "value": 3000 + }, + { + "color": "red", + "value": 5000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "RPS" + }, + "properties": [ + { + "id": "unit", + "value": "reqps" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Requests" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Success %" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "#EAB839", + "value": 60 + }, + { + "color": "green", + "value": 90 + } + ] + } + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.cellOptions", + "value": { + "applyToRow": false, + "mode": "gradient", + "type": "color-background", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "gRPC Method" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "auto", + "wrapText": false + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "gRPC Service" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "auto" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 71 + }, + "id": 36, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "enablePagination": true, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (rpc_service, rpc_method) (rate(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "rps" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (rpc_service, rpc_method) (increase(rpc_server_duration_milliseconds_sum{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range])) / sum by (rpc_service, rpc_method) (increase(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "avg-response-time-seconds" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(.95, sum(rate(rpc_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range])) by (rpc_service, rpc_method, le))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (rpc_service, rpc_method) (increase(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range]))", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "requests" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (rpc_service, rpc_method) (increase(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\", rpc_grpc_status_code=\"0\"}[$__range])) / sum by (rpc_service, rpc_method) (increase(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__range])) * 100", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "success-rate" + } + ], + "title": "Overview", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "rpc_method", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time 1": true, + "Time 2": true, + "Time 3": true, + "Time 4": true, + "Time 5": true, + "rpc_service 2": true, + "rpc_service 3": true, + "rpc_service 4": true, + "rpc_service 5": true + }, + "includeByName": {}, + "indexByName": { + "Time 1": 2, + "Time 2": 4, + "Time 3": 7, + "Time 4": 10, + "Time 5": 13, + "Value #avg-response-time-seconds": 6, + "Value #p95": 9, + "Value #requests": 12, + "Value #rps": 3, + "Value #success-rate": 15, + "rpc_method": 1, + "rpc_service 1": 0, + "rpc_service 2": 5, + "rpc_service 3": 8, + "rpc_service 4": 11, + "rpc_service 5": 14 + }, + "renameByName": { + "Value #avg-response-time-seconds": "Average", + "Value #p95": "P95", + "Value #requests": "Requests", + "Value #rps": "RPS", + "Value #success-rate": "Success %", + "rpc_method": "gRPC Method", + "rpc_service 1": "gRPC Service" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 79 + }, + "id": 32, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__rate_interval])) by (rpc_service, rpc_method)", + "legendFormat": "{{rpc_service}} {{rpc_method}}", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 79 + }, + "id": 33, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\", rpc_grpc_status_code=\"0\"}[$__rate_interval])) by (rpc_service, rpc_method)", + "hide": false, + "legendFormat": "{{rpc_service}} {{rpc_method}} - Ok", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(rpc_server_duration_milliseconds_count{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\", rpc_grpc_status_code!=\"0\"}[$__rate_interval])) by (rpc_service, rpc_method, rpc_grpc_status_code)", + "hide": false, + "legendFormat": "{{rpc_service}} {{rpc_method}} - {{rpc_grpc_status_code}}", + "range": true, + "refId": "B" + } + ], + "title": "Response Status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 87 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(.95, sum(rate(rpc_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__rate_interval])) by (rpc_service, rpc_method, le))", + "legendFormat": "{{rpc_service}} {{rpc_method}}", + "range": true, + "refId": "A" + } + ], + "title": "P95 Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 87 + }, + "id": 52, + "maxDataPoints": 20, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "green", + "mode": "scheme", + "reverse": true, + "scale": "exponential", + "scheme": "Blues", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": false, + "showLegend": true + }, + "rowsFrame": { + "layout": "unknown" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "ms" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (le) (increase(rpc_server_duration_milliseconds_bucket{service=\"$service\", environment=\"$environment\", rpc_service=~\"$grpc_service\", rpc_method=~\"$grpc_method\"}[$__interval]))", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Response Times", + "type": "heatmap" + }, + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 95 + }, + "id": 37, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "hide": false, + "key": "Q-f807f3aa-fde3-4f3d-96e1-ebff01a76cb9-0", + "limit": 20, + "query": "{ resource.service.name = \"$service\" && resource.environment = \"$environment\" && kind = server && span.rpc.service=~\"$grpc_service\" && span.rpc.method=~\"$grpc_method\" && status = error } || { resource.service.name = \"$service\" && resource.environment = \"$environment\" && kind = server && span.rpc.service=~\"$grpc_service\" && span.rpc.method=~\"$grpc_method\" } >> { status = error }", + "queryType": "traceql", + "refId": "A", + "tableType": "traces" + } + ], + "title": "Error Traces", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 106 + }, + "id": 42, + "panels": [], + "title": "Worker", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 107 + }, + "id": 40, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (worker_jobs_available_count{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"})", + "format": "heatmap", + "legendFormat": "Available: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "A" + } + ], + "title": "Available Jobs", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 107 + }, + "id": 44, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (increase(worker_jobs_enqueued_total{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval]))", + "format": "heatmap", + "legendFormat": "Enqueued: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "A" + } + ], + "title": "Jobs Enqueued", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "percent" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "C" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "D" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 115 + }, + "id": 45, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (increase(worker_jobs_completed_total{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval]))", + "format": "heatmap", + "hide": false, + "instant": false, + "legendFormat": "Completed: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (increase(worker_jobs_discarded_total{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval]))", + "format": "heatmap", + "hide": false, + "legendFormat": "Discarded: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (increase(worker_jobs_cancelled_total{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval]))", + "format": "heatmap", + "hide": false, + "legendFormat": "Cancelled: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "D" + } + ], + "title": "Jobs Completed", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "shades" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 115 + }, + "id": 46, + "maxDataPoints": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (job_kind, job_queue, job_priority) (increase(worker_jobs_failed_total{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval]))", + "format": "heatmap", + "legendFormat": "Failed: {{job_kind}} - {{job_queue}}[{{job_priority}}]", + "range": true, + "refId": "A" + } + ], + "title": "Jobs Failed", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 123 + }, + "id": 54, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"river.worker/.*\", job_kind=~\"$job_kind\"}[$__rate_interval])) by (span_name)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Job Rates", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "shades" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "river.worker/echo" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 123 + }, + "id": 55, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"river.worker/.*\", job_kind=~\"$job_kind\"}[$__rate_interval])) by (span_name)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Error Rates", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 25, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 131 + }, + "id": 53, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"$service\", environment=\"$environment\", span_kind=\"SPAN_KIND_INTERNAL\", span_name=~\"river.worker/.*\", job_kind=~\"$job_kind\"}[$__rate_interval])) by (span_name, status_code, le))", + "legendFormat": "{{span_name}}: {{status_code}}", + "range": true, + "refId": "A" + } + ], + "title": "P95 Job Latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "description": "", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 131 + }, + "id": 50, + "maxDataPoints": 20, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "mimir" + }, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(0.95, sum by (le) (increase(worker_jobs_queue_wait_milliseconds_bucket{service=\"$service\", environment=\"$environment\", job_kind=~\"$job_kind\"}[$__interval])))", + "format": "heatmap", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "P95 Queue Latency", + "type": "gauge" + }, + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 139 + }, + "id": 51, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "hide": false, + "key": "Q-f807f3aa-fde3-4f3d-96e1-ebff01a76cb9-0", + "limit": 20, + "query": "{ resource.service.name = \"$service\" && resource.environment = \"$environment\" && span.job.kind =~ \"$job_kind\" && status = error } || { resource.service.name = \"$service\" && resource.environment = \"$environment\" && span.job.kind =~ \"$job_kind\" } >> { status = error }", + "queryType": "traceql", + "refId": "A", + "tableType": "traces" + } + ], + "title": "Error Traces", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 150 + }, + "id": 6, + "panels": [], + "title": "Logs", + "type": "row" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 151 + }, + "id": 9, + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"})[$__auto]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Count", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 18, + "x": 6, + "y": 151 + }, + "id": 12, + "interval": "1m", + "maxDataPoints": "", + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": true, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "right", + "reverse": false + } + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(5, sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"} | json | __error__=\"\")[$__auto])) by (file))", + "legendFormat": "{{file}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Volume by File", + "type": "heatmap" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 155 + }, + "id": 10, + "maxDataPoints": 100, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"} | json | detected_level = \"error\")[$__auto]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Error Log Count", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 1, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepBefore", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "info" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 159 + }, + "id": 1, + "interval": "1m", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "zXvfRSSVz" + }, + "editorMode": "code", + "expr": "sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"} | json | __error__=\"\")[$__auto])) by (detected_level)", + "legendFormat": "{{level}}", + "queryType": "range", + "range": true, + "refId": "A" + } + ], + "title": "Log Volume", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "info" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 159 + }, + "id": 14, + "interval": "1m", + "maxDataPoints": "", + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "6.4.3", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"} | json | __error__=\"\")[$__range])) by (detected_level)", + "legendFormat": "{{detected_level}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs by Level", + "type": "piechart" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "stdout" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 159 + }, + "id": 13, + "maxDataPoints": 100, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time(({service_name=\"$service\", environment=\"$environment\"})[$__range])) by (logstream)", + "legendFormat": "{{logstream}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs by Stream", + "type": "piechart" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 167 + }, + "id": 5, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "pluginVersion": "11.1.4", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{service_name=\"$service\", environment=\"$environment\"} | json | line_format `{{__line__}}` | detected_level=~\"$log_level\" |~ \"(?i).*($log_search).*\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "Logs (Searchable)", + "type": "logs" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "countup" + ], + "value": [ + "countup" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": false, + "label": "Service", + "multi": false, + "name": "service", + "options": [], + "query": { + "label": "service.name", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": { + "selected": true, + "text": [ + "dev" + ], + "value": [ + "dev" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": false, + "label": "Environment", + "multi": false, + "name": "environment", + "options": [], + "query": { + "label": "environment", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "Endpoint Service", + "multi": true, + "name": "endpoint_service", + "options": [], + "query": { + "label": "endpoint.service", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "Endpoint Method", + "multi": true, + "name": "endpoint_method", + "options": [], + "query": { + "label": "endpoint.method", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "HTTP Method", + "multi": true, + "name": "http_method", + "options": [], + "query": { + "label": "http.method", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "HTTP Route", + "multi": true, + "name": "http_route", + "options": [], + "query": { + "label": "http.route", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "gRPC Service", + "multi": true, + "name": "grpc_service", + "options": [], + "query": { + "label": "rpc.service", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "gRPC Method", + "multi": true, + "name": "grpc_method", + "options": [], + "query": { + "label": "rpc.method", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "Job Kind", + "multi": true, + "name": "job_kind", + "options": [], + "query": { + "label": "job.kind", + "refId": "TempoDatasourceVariableQueryEditor-VariableQuery", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "definition": "", + "hide": 0, + "includeAll": true, + "label": "Log Level", + "multi": true, + "name": "log_level", + "options": [], + "query": { + "label": "level", + "refId": "LokiVariableQueryEditor-VariableQuery", + "stream": "{service_name=\"$service\", environment=\"$environment\"}", + "type": 1 + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "", + "value": "" + }, + "hide": 0, + "label": "Log Search", + "name": "log_search", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Service O11Y", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/infra/environments/dev/compose/grafana/provisioning/dashboards/dashboards.yaml b/infra/environments/dev/compose/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000..b39257b --- /dev/null +++ b/infra/environments/dev/compose/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: 1 + +providers: +- name: 'Grafana' + orgId: 1 + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/infra/environments/dev/compose/grafana/provisioning/datasources/datasources.yaml b/infra/environments/dev/compose/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000..bfd8bd6 --- /dev/null +++ b/infra/environments/dev/compose/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,70 @@ +--- +apiVersion: 1 + +prune: true + +datasources: +- name: Mimir + type: prometheus + access: proxy + uid: mimir + url: http://mimir:3300/prometheus + jsonData: + httpMethod: POST + exemplarTraceIdDestinations: + - datasourceUid: tempo + name: trace_id + +- name: Tempo + type: tempo + access: proxy + uid: tempo + url: http://tempo:3200 + jsonData: + tracesToMetrics: + datasourceUid: mimir + spanStartTimeShift: '-5m' + spanEndTimeShift: '5m' + tags: + - {key: 'service.name', value: 'service'} + - {key: 'service.version', value: 'service_version'} + - {key: 'tier'} + - {key: 'environment'} + - {key: 'http.method', value: 'http_method'} + - {key: 'http.target', value: 'http_target'} + - {key: 'http.status_code', value: 'http_status_code'} + - {key: 'rpc.service', value: 'rpc_service'} + - {key: 'rpc.method', value: 'rpc_method'} + - {key: 'rpc.grpc.status_code', value: 'rpc_grpc_status_code'} + - {key: 'endpoint.service', value: 'endpoint_service'} + - {key: 'endpoint.method', value: endpointa_method'} + - {key: 'job.worker', value: 'job_worker'} + - {key: 'job.kind', value: 'job_kind'} + - {key: 'job.queue', value: 'job_queue'} + - {key: 'job.priority', value: 'job_priority'} + queries: + - name: 'Spanmetrics Latency' + query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[1m]))' + tracesToLogsV2: + datasourceUid: loki + spanStartTimeShift: '-5m' + spanEndTimeShift: '5m' + customQuery: true + query: '{service_name=`$${__span.tags["service.name"]}`} | json | trace_id = `$${__span.traceId}` | span_id = `$${__span.spanId}`' + nodeGraph: + enabled: true + serviceMap: + datasourceUid: mimir + +- name: Loki + type: loki + access: proxy + uid: loki + url: http://loki:3100 + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"trace_id":"(\w+)"' + name: trace_id + url: '$${__value.raw}' + urlDisplayLabel: 'View Trace' diff --git a/infra/environments/dev/compose/grafana/provisioning/plugins/loki-explorer-app.yaml b/infra/environments/dev/compose/grafana/provisioning/plugins/loki-explorer-app.yaml new file mode 100644 index 0000000..b531d8f --- /dev/null +++ b/infra/environments/dev/compose/grafana/provisioning/plugins/loki-explorer-app.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: 1 + +apps: +- type: grafana-lokiexplore-app + orgId: 1 + disabled: false diff --git a/infra/environments/dev/compose/loki/config.yaml b/infra/environments/dev/compose/loki/config.yaml new file mode 100644 index 0000000..e6e9348 --- /dev/null +++ b/infra/environments/dev/compose/loki/config.yaml @@ -0,0 +1,33 @@ +--- +auth_enabled: false + +server: + http_listen_port: 3100 + log_level: warn + log_format: json + +common: + instance_addr: 127.0.0.1 + replication_factor: 1 + path_prefix: /tmp/loki + ring: + kvstore: + store: memberlist + +pattern_ingester: + enabled: true + +storage_config: + filesystem: + directory: /tmp/loki/chunks + +schema_config: + configs: + - from: 2024-08-28 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + diff --git a/infra/environments/dev/compose/mimir/config.yaml b/infra/environments/dev/compose/mimir/config.yaml new file mode 100644 index 0000000..ad9d9b5 --- /dev/null +++ b/infra/environments/dev/compose/mimir/config.yaml @@ -0,0 +1,41 @@ +--- +multitenancy_enabled: false + +server: + http_listen_port: 3300 + log_level: warn + log_format: json + +blocks_storage: + backend: filesystem + filesystem: + dir: /tmp/mimir/tsdb-data + bucket_store: + sync_dir: /tmp/mimir/tsdb-sync + tsdb: + dir: /tmp/mimir/tsdb + +distributor: + ring: + kvstore: + store: memberlist + +ingester: + ring: + replication_factor: 1 + kvstore: + store: memberlist + +compactor: + data_dir: /tmp/mimir/data-compactor + sharding_ring: + kvstore: + store: memberlist + +store_gateway: + sharding_ring: + replication_factor: 1 + +limits: + native_histograms_ingestion_enabled: true + max_global_exemplars_per_user: 100000 diff --git a/infra/environments/dev/compose/otel-collector/config.yaml b/infra/environments/dev/compose/otel-collector/config.yaml new file mode 100644 index 0000000..ba189ed --- /dev/null +++ b/infra/environments/dev/compose/otel-collector/config.yaml @@ -0,0 +1,211 @@ +--- +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + + prometheus: + config: + scrape_configs: + - job_name: postgres + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=postgres] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 9187 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: grafana + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=grafana] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 3000 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: otel-collector + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=otel-collector] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 8888 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: promtail + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=promtail] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 3080 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: loki + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=loki] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 3100 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: tempo + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=tempo] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 3200 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + + - job_name: mimir + scrape_interval: 30s + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=mimir] + relabel_configs: + - action: 'keep' + source_labels: ['__meta_docker_port_private'] + regex: 3300 + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +processors: + batch: + + tail_sampling: + decision_wait: 30s + policies: + - name: sample-error-traces + type: status_code + status_code: {status_codes: [ERROR]} + - name: sample-long-traces + type: latency + latency: {threshold_ms: 200} + + transform/otlp: + error_mode: ignore + metric_statements: + - context: datapoint + statements: + - set(attributes["service"], resource.attributes["service.name"]) + - set(attributes["service_version"], resource.attributes["service.version"]) + - set(attributes["tier"], resource.attributes["tier"]) + - set(attributes["environment"], resource.attributes["environment"]) + +exporters: + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + + prometheusremotewrite/mimir: + endpoint: http://mimir:3300/api/v1/push + tls: + insecure: true + +service: + telemetry: + logs: + encoding: json + + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/tempo] + + metrics/otlp: + receivers: [otlp] + processors: [batch, transform/otlp] + exporters: [prometheusremotewrite/mimir] + + metrics/prometheus: + receivers: [prometheus] + processors: [batch] + exporters: [prometheusremotewrite/mimir] diff --git a/infra/environments/dev/compose/postgres/init/river.sql b/infra/environments/dev/compose/postgres/init/river.sql new file mode 100644 index 0000000..e42ee43 --- /dev/null +++ b/infra/environments/dev/compose/postgres/init/river.sql @@ -0,0 +1,271 @@ +-- River migration 001 [up] +CREATE TABLE river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX ON river_migration USING btree(version); + +-- River migration 002 [up] +CREATE TYPE river_job_state AS ENUM( + 'available', + 'cancelled', + 'completed', + 'discarded', + 'retryable', + 'running', + 'scheduled' +); + +CREATE TABLE river_job( + -- 8 bytes + id bigserial PRIMARY KEY, + + -- 8 bytes (4 bytes + 2 bytes + 2 bytes) + -- + -- `state` is kept near the top of the table for operator convenience -- when + -- looking at jobs with `SELECT *` it'll appear first after ID. The other two + -- fields aren't as important but are kept adjacent to `state` for alignment + -- to get an 8-byte block. + state river_job_state NOT NULL DEFAULT 'available', + attempt smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL, + + -- 8 bytes each (no alignment needed) + attempted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + finalized_at timestamptz, + scheduled_at timestamptz NOT NULL DEFAULT NOW(), + + -- 2 bytes (some wasted padding probably) + priority smallint NOT NULL DEFAULT 1, + + -- types stored out-of-band + args jsonb, + attempted_by text[], + errors jsonb[], + kind text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + queue text NOT NULL DEFAULT 'default', + tags varchar(255)[], + + CONSTRAINT finalized_or_finalized_at_null CHECK ((state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL), + CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128), + CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128) +); + +-- We may want to consider adding another property here after `kind` if it seems +-- like it'd be useful for something. +CREATE INDEX river_job_kind ON river_job USING btree(kind); + +CREATE INDEX river_job_state_and_finalized_at_index ON river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL; + +CREATE INDEX river_job_prioritized_fetching_index ON river_job USING btree(state, queue, priority, scheduled_at, id); + +CREATE INDEX river_job_args_index ON river_job USING GIN(args); + +CREATE INDEX river_job_metadata_index ON river_job USING GIN(metadata); + +CREATE OR REPLACE FUNCTION river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notificiations within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON river_job + FOR EACH ROW + EXECUTE PROCEDURE river_job_notify(); + +CREATE UNLOGGED TABLE river_leader( + -- 8 bytes each (no alignment needed) + elected_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + + -- types stored out-of-band + leader_id text NOT NULL, + name text PRIMARY KEY, + + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128) +); + +-- River migration 003 [up] +ALTER TABLE river_job ALTER COLUMN tags SET DEFAULT '{}'; +UPDATE river_job SET tags = '{}' WHERE tags IS NULL; +ALTER TABLE river_job ALTER COLUMN tags SET NOT NULL; + +-- River migration 004 [up] +-- The args column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE river_job ALTER COLUMN args SET DEFAULT '{}'; +UPDATE river_job SET args = '{}' WHERE args IS NULL; +ALTER TABLE river_job ALTER COLUMN args SET NOT NULL; +ALTER TABLE river_job ALTER COLUMN args DROP DEFAULT; + +-- The metadata column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE river_job ALTER COLUMN metadata SET DEFAULT '{}'; +UPDATE river_job SET metadata = '{}' WHERE metadata IS NULL; +ALTER TABLE river_job ALTER COLUMN metadata SET NOT NULL; + +-- The 'pending' job state will be used for upcoming functionality: +ALTER TYPE river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded'; + +ALTER TABLE river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) +); + +DROP TRIGGER river_notify ON river_job; +DROP FUNCTION river_job_notify; + +CREATE TABLE river_queue( + name text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + metadata jsonb NOT NULL DEFAULT '{}' ::jsonb, + paused_at timestamptz, + updated_at timestamptz NOT NULL +); + +ALTER TABLE river_leader + ALTER COLUMN name SET DEFAULT 'default', + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (name = 'default'); + +-- River migration 005 [up] +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('river_migration') IS NOT NULL) THEN + ALTER TABLE river_migration + RENAME TO river_migration_old; + + CREATE TABLE river_migration( + line TEXT NOT NULL, + version bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) + ); + + INSERT INTO river_migration + (created_at, line, version) + SELECT created_at, 'main', version + FROM river_migration_old; + + DROP TABLE river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Add `river_job.unique_key` and bring up an index on it. +-- + +-- These statements use `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +ALTER TABLE river_job + ADD COLUMN IF NOT EXISTS unique_key bytea; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE UNLOGGED TABLE river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE UNLOGGED TABLE river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- River migration 006 [up] +CREATE OR REPLACE FUNCTION river_job_state_in_bitmask(bitmask BIT(8), state river_job_state) +RETURNS boolean +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE state + WHEN 'available' THEN get_bit(bitmask, 7) + WHEN 'cancelled' THEN get_bit(bitmask, 6) + WHEN 'completed' THEN get_bit(bitmask, 5) + WHEN 'discarded' THEN get_bit(bitmask, 4) + WHEN 'pending' THEN get_bit(bitmask, 3) + WHEN 'retryable' THEN get_bit(bitmask, 2) + WHEN 'running' THEN get_bit(bitmask, 1) + WHEN 'scheduled' THEN get_bit(bitmask, 0) + ELSE 0 + END = 1; +$$; + +-- +-- Add `river_job.unique_states` and bring up an index on it. +-- +ALTER TABLE river_job ADD COLUMN unique_states BIT(8); + +-- This statements uses `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND river_job_state_in_bitmask(unique_states, state); + +-- Remove the old unique index. Users who are actively using the unique jobs +-- feature and who wish to avoid deploy downtime may want od drop this in a +-- subsequent migration once all jobs using the old unique system have been +-- completed (i.e. no more rows with non-null unique_key and null +-- unique_states). +DROP INDEX river_job_kind_unique_key_idx; diff --git a/infra/environments/dev/compose/promtail/config.yaml b/infra/environments/dev/compose/promtail/config.yaml new file mode 100644 index 0000000..1e2231e --- /dev/null +++ b/infra/environments/dev/compose/promtail/config.yaml @@ -0,0 +1,151 @@ +--- +server: + http_listen_port: 3080 + log_level: warn + +clients: +- url: http://loki:3100/loki/api/v1/push + +positions: + filename: /tmp/positions.yaml + +scrape_configs: +- job_name: countup + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=countup] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + pipeline_stages: + - docker: {} + - json: + expressions: + level: + - labels: + level: + +- job_name: grafana + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=grafana] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +- job_name: otel-collector + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=otel-collector] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +- job_name: promtail + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=promtail] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +- job_name: loki + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=loki] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +- job_name: tempo + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=tempo] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' + +- job_name: mimir + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: [countup_default] + - name: label + values: [service=mimir] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'logstream' + - source_labels: ['__meta_docker_container_label_service'] + target_label: 'service_name' + - action: labelmap + regex: '__meta_docker_container_label_(component|tier|environment)' diff --git a/infra/environments/dev/compose/tempo/config.yaml b/infra/environments/dev/compose/tempo/config.yaml new file mode 100644 index 0000000..086f9ad --- /dev/null +++ b/infra/environments/dev/compose/tempo/config.yaml @@ -0,0 +1,88 @@ +--- +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + log_level: warn + log_format: json + +storage: + trace: + backend: local + local: + path: /tmp/tempo/blocks + wal: + path: /tmp/tempo/wal + pool: + max_workers: 50 + queue_depth: 2000 + +distributor: + receivers: + otlp: + protocols: + grpc: + +ingester: + lifecycler: + ring: + replication_factor: 1 + +compactor: + ring: + kvstore: + store: memberlist + compaction: + block_retention: 168h + +metrics_generator: + ring: + kvstore: + store: memberlist + processor: + span_metrics: + dimensions: + - service.version + - tier + - environment + - endpoint.service + - endpoint.method + - http.method + - http.target + - http.status_code + - rpc.service + - rpc.method + - rpc.grpc.status_code + - job.worker + - job.kind + - job.queue + - job.priority + service_graphs: + dimensions: + - service.version + - tier + - environment + - endpoint.service + - endpoint.method + - http.method + - http.target + - http.status_code + - rpc.service + - rpc.method + - rpc.grpc.status_code + registry: + external_labels: + source: tempo + storage: + path: /tmp/tempo/metrics-generator/wal + remote_write: + - url: http://mimir:3300/api/v1/push + send_exemplars: true + traces_storage: + path: /tmp/tempo/metrics-generator/traces + +overrides: + defaults: + metrics_generator: + processors: [service-graphs, span-metrics] + trace_id_label_name: trace_id diff --git a/infra/environments/dev/gcp/main.tf b/infra/environments/dev/gcp/main.tf new file mode 100644 index 0000000..f5acc81 --- /dev/null +++ b/infra/environments/dev/gcp/main.tf @@ -0,0 +1,11 @@ +data "google_project" "this" { + project_id = var.google_project +} + +output "google_project" { + value = data.google_project.this +} + +output "hello" { + value = "world" +} diff --git a/infra/environments/dev/gcp/terraform.tf b/infra/environments/dev/gcp/terraform.tf new file mode 100644 index 0000000..7ff48f4 --- /dev/null +++ b/infra/environments/dev/gcp/terraform.tf @@ -0,0 +1,17 @@ +terraform { + required_version = "~> 1.9.8" + + required_providers { + google = { + source = "hashicorp/google" + version = "6.7.0" + } + } +} + +provider "google" { + project = var.google_project + region = var.google_region + + add_terraform_attribution_label = true +} diff --git a/infra/environments/dev/gcp/variables.tf b/infra/environments/dev/gcp/variables.tf new file mode 100644 index 0000000..18d8e90 --- /dev/null +++ b/infra/environments/dev/gcp/variables.tf @@ -0,0 +1,9 @@ +variable "google_project" { + type = string + default = "emp-jace-a850" +} + +variable "google_region" { + type = string + default = "europe-west1" +} diff --git a/infra/environments/prd/.gitkeep b/infra/environments/prd/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/spacelift/init/.terraform.lock.hcl b/infra/spacelift/init/.terraform.lock.hcl new file mode 100644 index 0000000..4d2e71d --- /dev/null +++ b/infra/spacelift/init/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.7.0" + constraints = "6.7.0" + hashes = [ + "h1:2R/lqkaJ6+JzXLvMjV9RpS800/D+JBVJdUr5cMTCtqA=", + "zh:16ac63e56986916015637bdc26a93e375aa84f22381d37dea51b227bc8fd58e3", + "zh:3d27c11cfd55394e247b01dc5d8bf4b892940ac0b66785cf565fbbabe8b8363b", + "zh:40011688dd3d5294f92bc0d85f30f26c427adc9a0e5c5053ca71a66f322e6edd", + "zh:84f2b94480c0979fbef001bc040dcaa5ac7b7d3cb47edc24ef612f6ede8ecb84", + "zh:9350b88bfeedf91176ed8447e368a980b0f5c9ad5f6bc0eff62e8889888a60df", + "zh:a0aba100e12c1a4e45ed9c66a95edb6c1f51d4f88ac4a38e0f64ee057128b23d", + "zh:cc5b520ea5806b967559f6f7fa07e7f0e9fe380bfb68d2f8b5afc1c94e99cd70", + "zh:d1eaea3ed952dff0337930938ae031841175725a6da1a512d8dfd649b1e2a83f", + "zh:d9deed8a673ce1d1f9ebcb6186ee4068933720d0d97ef7b627d7d0f819b67eed", + "zh:df342d4ab9cd3d1e26bd338bc4a2f0fd136c96f8861ce0ab63a7e6e41f62254b", + "zh:f0438dbdacdcfc2727b09752f682c96f5f575fdb3282b6fb1721756d83b390c7", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/spacelift-io/spacelift" { + version = "1.16.1" + constraints = "1.16.1" + hashes = [ + "h1:J3/WR9swDZOAge/mfWtbqInFQeaAtpV3/Brxrk2LbG4=", + "zh:284045c910a2c22b2c68b3a805fd01b200f1434fd4d285e9bd47bf655ddcfd18", + "zh:8a17eea1a1c438fcd9f236b1165c292791e0dc079364c0c628d25ba3f954dcd5", + "zh:8da694a38a174e69b074c6104c05ea3a691228adbbdca1c5771b56fb436e3c20", + "zh:8f2d3ba55a05ff3723c2d0c158f68bd3dfb8038169ebbef113bb88b729494785", + "zh:9a49905594efb11ec9039057bb5101d8e7ad6e2c7cf0dfb0ea09873dc3433e76", + "zh:a976fe76e94fb1521a8bc00b3a885d0ae666d552f6aed44563847ea51a027f78", + ] +} diff --git a/infra/spacelift/init/contexts.tf b/infra/spacelift/init/contexts.tf new file mode 100644 index 0000000..6d1044e --- /dev/null +++ b/infra/spacelift/init/contexts.tf @@ -0,0 +1,34 @@ +resource "spacelift_context" "google_provider" { + space_id = "root" + name = "google-provider" + description = "Configuration for the Terraform google provider" + labels = ["autoattach:google-provider"] +} + +resource "spacelift_mounted_file" "google_application_credentials" { + context_id = spacelift_context.google_provider.id + relative_path = "gcp.json" + content = filebase64("${path.module}/.tfvars/credentials.json") + write_only = false +} + +resource "spacelift_environment_variable" "google_application_credentials" { + context_id = spacelift_context.google_provider.id + name = "GOOGLE_APPLICATION_CREDENTIALS" + value = "/mnt/workspace/${spacelift_mounted_file.google_application_credentials.relative_path}" + write_only = false +} + +resource "spacelift_environment_variable" "google_project" { + context_id = spacelift_context.google_provider.id + name = "GOOGLE_PROJECT" + value = "emp-jace-a850" + write_only = false +} + +resource "spacelift_environment_variable" "google_region" { + context_id = spacelift_context.google_provider.id + name = "GOOGLE_REGION" + value = "europe-west1" + write_only = false +} diff --git a/infra/spacelift/init/spacelift.tf b/infra/spacelift/init/spacelift.tf new file mode 100644 index 0000000..a2fdfc2 --- /dev/null +++ b/infra/spacelift/init/spacelift.tf @@ -0,0 +1,59 @@ +resource "google_service_account" "spacelift" { + project = var.google_project + account_id = "spacelift" + display_name = "Spacelift" +} + +resource "google_iam_workload_identity_pool" "spacelift" { + project = var.google_project + workload_identity_pool_id = "spacelift" +} + +resource "google_iam_workload_identity_pool_provider" "spacelift" { + project = var.google_project + workload_identity_pool_id = google_iam_workload_identity_pool.spacelift.workload_identity_pool_id + workload_identity_pool_provider_id = "jace-ys" + display_name = "jace-ys@spacelift.io" + description = "OIDC identity pool provider for Spacelift" + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.space" = "assertion.spaceId" + } + + oidc { + allowed_audiences = ["jace-ys.app.spacelift.io"] + issuer_uri = "https://jace-ys.app.spacelift.io" + } +} + +resource "google_service_account_iam_binding" "spacelift" { + service_account_id = google_service_account.spacelift.name + role = "roles/iam.workloadIdentityUser" + members = [ + "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.spacelift.name}/attribute.space/root" + ] +} + +resource "spacelift_stack" "root" { + space_id = "root" + name = "root" + description = "Root stack" + repository = "countup" + branch = "main" + project_root = "spacelift/root" + terraform_version = "1.5.7" + terraform_smart_sanitization = true + administrative = true +} + +resource "spacelift_gcp_service_account" "root" { + stack_id = spacelift_stack.root.id + token_scopes = [ + "https://www.googleapis.com/auth/compute", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/ndev.clouddns.readwrite", + "https://www.googleapis.com/auth/devstorage.full_control", + "https://www.googleapis.com/auth/userinfo.email", + ] +} diff --git a/infra/spacelift/init/terraform.tf b/infra/spacelift/init/terraform.tf new file mode 100644 index 0000000..8d957fe --- /dev/null +++ b/infra/spacelift/init/terraform.tf @@ -0,0 +1,27 @@ +terraform { + required_version = "1.5.7" + + required_providers { + google = { + source = "hashicorp/google" + version = "6.7.0" + } + spacelift = { + source = "spacelift-io/spacelift" + version = "1.16.1" + } + } +} + +provider "google" { + project = var.google_project + region = var.google_region + + add_terraform_attribution_label = true +} + +provider "spacelift" { + api_key_endpoint = var.spacelift_endpoint + api_key_id = var.spacelift_key_id + api_key_secret = var.spacelift_key_secret +} diff --git a/infra/spacelift/init/variables.tf b/infra/spacelift/init/variables.tf new file mode 100644 index 0000000..bbea4e5 --- /dev/null +++ b/infra/spacelift/init/variables.tf @@ -0,0 +1,24 @@ +variable "google_project" { + type = string + default = "emp-jace-a850" +} + +variable "google_region" { + type = string + default = "europe-west1" +} + +variable "spacelift_endpoint" { + type = string + default = "https://jace-ys.app.spacelift.io" +} + +variable "spacelift_key_id" { + type = string + default = "01JAN9TH8H60PCN3N30V1ZES81" +} + +variable "spacelift_key_secret" { + type = string + sensitive = true +}