From 1d238fd4467e6974ac080c06fc3038de337e0395 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 14:49:22 +0800 Subject: [PATCH 01/17] feat: add docker-compose local dev stack with profiles Adds docker-compose.local.yml to the cookiecutter template with compose profiles for optional dependencies and observability. - Default: service only (builds from Dockerfile) - PROFILES="deps": adds Postgres, Redis, Adminer - PROFILES="deps obs": adds Prometheus, Grafana - Makefile targets: local-stack, local-stack-down, local-stack-logs, local-stack-rm, local-stack-reset, local-psql - PROFILES variable flows through all targets - deploy/prometheus.yml for obs profile scrape config - AGENTS.md documents all targets and endpoint URLs - 4 new tests + updated expected files list (49 tests pass) --- tests/test_cookiecutter_generation.py | 39 ++++++++++++++ {{cookiecutter.app_name}}/AGENTS.md | 23 +++++++++ {{cookiecutter.app_name}}/Makefile | 34 ++++++++++++- .../deploy/prometheus.yml | 7 +++ .../docker-compose.local.yml | 51 +++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 {{cookiecutter.app_name}}/deploy/prometheus.yml create mode 100644 {{cookiecutter.app_name}}/docker-compose.local.yml diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 76149b1..e67a02c 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -64,6 +64,8 @@ def test_expected_files_exist(self, bake_project): "main.go", "Makefile", "Dockerfile", + "docker-compose.local.yml", + "deploy/prometheus.yml", "go.mod", "README.md", "AGENTS.md", @@ -277,6 +279,16 @@ def test_go_tool_commands(self, bake_project): assert "go tool mockery" in content assert "go tool govulncheck" in content + def test_local_stack_targets(self, bake_project): + project = bake_project() + content = (project / "Makefile").read_text() + assert "local-stack:" in content + assert "local-stack-down:" in content + assert "local-stack-logs:" in content + assert "local-stack-reset:" in content + assert "local-psql:" in content + assert "docker-compose.local.yml" in content + def test_bench_run_pattern(self, bake_project): project = bake_project() content = (project / "Makefile").read_text() @@ -346,6 +358,33 @@ def test_gitlab_ci_go_tool_cobertura(self, bake_project): # --------------------------------------------------------------------------- +class TestDockerCompose: + def test_compose_service_name(self, bake_project): + project = bake_project() + content = (project / "docker-compose.local.yml").read_text() + assert "testservice:" in content + assert "9090:9090" in content + assert "9091:9091" in content + + def test_compose_profiles(self, bake_project): + project = bake_project() + content = (project / "docker-compose.local.yml").read_text() + assert 'profiles: ["deps"]' in content + assert 'profiles: ["obs"]' in content + + def test_compose_db_name(self, bake_project): + project = bake_project() + content = (project / "docker-compose.local.yml").read_text() + assert "POSTGRES_DB: testservice_dev" in content + + def test_agents_md_local_stack(self, bake_project): + project = bake_project() + content = (project / "AGENTS.md").read_text() + assert "make local-stack" in content + assert "PROFILES=" in content + assert "localhost:9091" in content + + class TestConfigFiles: def test_gitignore_entries(self, bake_project): project = bake_project() diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index 2bafef6..cbae656 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -103,6 +103,29 @@ GOPRIVATE is pre-configured in Makefile, Dockerfile, and CI workflows. For priva - **CI**: uncomment the auth steps in `.github/workflows/go.yml` or `.gitlab-ci.yml` - See [Private Modules guide](https://docs.coldbrew.cloud/howto/private-modules/) for details +## Local Development Stack + +Start the service and dependencies with docker-compose: + +```bash +make local-stack # service only +make local-stack PROFILES="deps" # + Postgres, Redis, Adminer +make local-stack PROFILES="deps obs" # + Prometheus, Grafana +make local-stack-logs # follow logs +make local-stack-down # stop stack +make local-stack-reset # stop, remove, restart +make local-psql # open Postgres shell +``` + +Endpoints when running with all profiles: +- Service HTTP/Swagger: http://localhost:9091/swagger/ +- Service gRPC: localhost:9090 +- Postgres: localhost:5433 (user: postgres, password: postgres, db: {{cookiecutter.app_name}}_dev) +- Redis: localhost:6379 +- Adminer (DB UI): http://localhost:8088 +- Prometheus: http://localhost:9100 +- Grafana: http://localhost:3000 (admin/admin) + ## Rules - **Never edit generated files** — files in `proto/*.pb.go`, `proto/*_grpc.pb.go`, `proto/*.gw.go` are generated. Edit the `.proto` file and run `make generate`. diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index b5bdce5..964a893 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck +.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-rm local-stack-reset local-psql BIN_NAME={{cookiecutter.app_name}} GOPRIVATE ?= {{cookiecutter.goprivate}} @@ -35,6 +35,11 @@ help: @echo ' make run-docker Run the project in a docker container.' @echo ' make runj Run the project locally with jq log parsing.' @echo ' make test Run tests.' + @echo ' make local-stack Start local dev stack (PROFILES="deps" for db/redis, "deps obs" for full).' + @echo ' make local-stack-down Stop local dev stack.' + @echo ' make local-stack-logs Follow local dev stack logs.' + @echo ' make local-stack-reset Reset local dev stack (down + rm + up).' + @echo ' make local-psql Open psql shell in local Postgres.' @echo build: @@ -100,3 +105,30 @@ runj: build run-docker: build-docker docker run -p 9091:9091 -p 9090:9090 --env-file local.env ${IMAGE_NAME}:local + +# Local development stack (docker-compose) +# Usage: +# make local-stack # service only +# make local-stack PROFILES="deps" # + postgres, redis, adminer +# make local-stack PROFILES="deps obs" # + prometheus, grafana +# make local-stack-down PROFILES="deps" # tear down with deps + +COMPOSE_FILE := docker-compose.local.yml +PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) + +local-stack: + docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait + +local-stack-down: + docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) down + +local-stack-logs: + docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) logs -f + +local-stack-rm: + docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) rm + +local-stack-reset: local-stack-down local-stack-rm local-stack + +local-psql: + docker exec -ti {{cookiecutter.app_name}}-db-1 bash -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' diff --git a/{{cookiecutter.app_name}}/deploy/prometheus.yml b/{{cookiecutter.app_name}}/deploy/prometheus.yml new file mode 100644 index 0000000..10be11d --- /dev/null +++ b/{{cookiecutter.app_name}}/deploy/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: {{cookiecutter.app_name}} + static_configs: + - targets: ["{{cookiecutter.app_name}}:9091"] diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml new file mode 100644 index 0000000..fd80c8a --- /dev/null +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -0,0 +1,51 @@ +services: + {{cookiecutter.app_name}}: + build: . + restart: always + ports: + - "9090:9090" + - "9091:9091" + env_file: + - local.env + + db: + image: postgres:17-alpine + restart: always + profiles: ["deps"] + ports: + - "5433:5432" + environment: + POSTGRES_DB: {{cookiecutter.app_name}}_dev + POSTGRES_PASSWORD: postgres + + redis: + image: redis:8-alpine + restart: always + profiles: ["deps"] + ports: + - "6379:6379" + + adminer: + image: adminer + restart: always + profiles: ["deps"] + ports: + - "8088:8080" + + prometheus: + image: prom/prometheus:latest + restart: always + profiles: ["obs"] + ports: + - "9100:9090" + volumes: + - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml + + grafana: + image: grafana/grafana:latest + restart: always + profiles: ["obs"] + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_PASSWORD: admin From 97f763e4a7d1bf17f340414eca60af0943b2c56a Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 15:52:29 +0800 Subject: [PATCH 02/17] fix: remove spurious 'build-' prefix from Dockerfile app label --- {{cookiecutter.app_name}}/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.app_name}}/Dockerfile b/{{cookiecutter.app_name}}/Dockerfile index e2d2aa8..487a0c8 100644 --- a/{{cookiecutter.app_name}}/Dockerfile +++ b/{{cookiecutter.app_name}}/Dockerfile @@ -1,7 +1,7 @@ # Build Stage FROM {{cookiecutter.docker_build_image}}:{{cookiecutter.docker_build_image_version}} AS build-stage -LABEL app="build-{{cookiecutter.app_name}}" +LABEL app="{{cookiecutter.app_name}}" LABEL REPO="https://{{cookiecutter.source_path}}/{{cookiecutter.app_name}}" ARG GOPRIVATE={{cookiecutter.goprivate}} From 3f379156cb66453fc8e75845b9487c7598df1ccd Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 17:57:46 +0800 Subject: [PATCH 03/17] feat: add Grafana dashboard, include_docker_compose flag, deploy/local/ layout - Pre-built ColdBrew gRPC dashboard (RED overview, status codes, latency by method, Go runtime) auto-provisioned in Grafana via obs profile - Grafana datasource auto-configured to Prometheus - All local dev infra moved to deploy/local/ (prometheus.yml, grafana/) - include_docker_compose flag in cookiecutter.json (default: true) - Post-gen hook removes docker-compose files when flag is false - COOKIECUTTER_SKIP_PROTO_INIT env var skips heavy Go toolchain steps in hooks, enabling fast test execution with hooks enabled - 52 tests pass including new: grafana provisioning, volumes, flag=false --- cookiecutter.json | 4 +- hooks/post_gen_project.py | 18 +- tests/conftest.py | 34 +- tests/test_cookiecutter_generation.py | 22 +- .../grafana/dashboards/coldbrew-service.json | 291 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 9 + .../provisioning/datasources/prometheus.yml | 7 + .../deploy/{ => local}/prometheus.yml | 0 .../docker-compose.local.yml | 5 +- 9 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 {{cookiecutter.app_name}}/deploy/local/grafana/dashboards/coldbrew-service.json create mode 100644 {{cookiecutter.app_name}}/deploy/local/grafana/provisioning/dashboards/dashboards.yml create mode 100644 {{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml rename {{cookiecutter.app_name}}/deploy/{ => local}/prometheus.yml (100%) diff --git a/cookiecutter.json b/cookiecutter.json index 7e53a56..4c4d4c3 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -9,10 +9,12 @@ "docker_image": "alpine:latest", "docker_build_image": "golang", "docker_build_image_version": ["1.26", "1.25"], + "include_docker_compose": true, "_copy_without_render": [ "third_party/OpenAPI/swagger-ui*", "third_party/*js.map", "third_party/*css.map", - "third_party/*css" + "third_party/*css", + "deploy/local/grafana/*" ] } diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 6fadd7a..52857b8 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -83,6 +83,22 @@ def setup_local_env(): if os.path.exists(example) and not os.path.exists(local): shutil.copy2(example, local) -init_proto() +def remove_docker_compose(): + """ + Removes docker-compose and deploy files when include_docker_compose is false + """ + import shutil + remove_file("docker-compose.local.yml") + deploy_dir = os.path.join(PROJECT_DIRECTORY, "deploy") + if os.path.exists(deploy_dir): + shutil.rmtree(deploy_dir) + +if os.environ.get("COOKIECUTTER_SKIP_PROTO_INIT") != "1": + init_proto() + setup_local_env() + +if "{{ cookiecutter.include_docker_compose }}".lower() not in ("true", "1", "yes"): + remove_docker_compose() + init_git() diff --git a/tests/conftest.py b/tests/conftest.py index 6493151..ef1a6a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,12 +29,15 @@ def default_context(): @pytest.fixture def bake_project(tmp_path, template_dir, default_context): - """Factory fixture that bakes a project without running hooks. + """Factory fixture that bakes a project. Returns a function that accepts optional context overrides and returns a pathlib.Path to the generated project directory. + + Pass with_hooks=True to enable post-generation hooks (skips proto init + via COOKIECUTTER_SKIP_PROTO_INIT=1 to keep tests fast). """ - def _bake(extra_context=None, full_context=None): + def _bake(extra_context=None, full_context=None, with_hooks=False): if full_context is not None: ctx = full_context else: @@ -42,13 +45,26 @@ def _bake(extra_context=None, full_context=None): if extra_context: ctx.update(extra_context) - project_dir = cookiecutter( - template_dir, - output_dir=str(tmp_path), - no_input=True, - extra_context=ctx, - accept_hooks=False, - ) + import os + old_env = os.environ.get("COOKIECUTTER_SKIP_PROTO_INIT") + if with_hooks: + os.environ["COOKIECUTTER_SKIP_PROTO_INIT"] = "1" + + try: + project_dir = cookiecutter( + template_dir, + output_dir=str(tmp_path), + no_input=True, + extra_context=ctx, + accept_hooks=with_hooks, + ) + finally: + if with_hooks: + if old_env is None: + os.environ.pop("COOKIECUTTER_SKIP_PROTO_INIT", None) + else: + os.environ["COOKIECUTTER_SKIP_PROTO_INIT"] = old_env + return Path(project_dir) return _bake diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index e67a02c..19621ca 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -65,7 +65,10 @@ def test_expected_files_exist(self, bake_project): "Makefile", "Dockerfile", "docker-compose.local.yml", - "deploy/prometheus.yml", + "deploy/local/prometheus.yml", + "deploy/local/grafana/provisioning/datasources/prometheus.yml", + "deploy/local/grafana/provisioning/dashboards/dashboards.yml", + "deploy/local/grafana/dashboards/coldbrew-service.json", "go.mod", "README.md", "AGENTS.md", @@ -384,6 +387,23 @@ def test_agents_md_local_stack(self, bake_project): assert "PROFILES=" in content assert "localhost:9091" in content + def test_grafana_provisioning_exists(self, bake_project): + project = bake_project() + assert (project / "deploy/local/grafana/provisioning/datasources/prometheus.yml").exists() + assert (project / "deploy/local/grafana/provisioning/dashboards/dashboards.yml").exists() + assert (project / "deploy/local/grafana/dashboards/coldbrew-service.json").exists() + + def test_grafana_volumes_in_compose(self, bake_project): + project = bake_project() + content = (project / "docker-compose.local.yml").read_text() + assert "deploy/local/grafana/provisioning:/etc/grafana/provisioning" in content + assert "deploy/local/grafana/dashboards:/var/lib/grafana/dashboards" in content + + def test_docker_compose_disabled(self, bake_project): + project = bake_project({"include_docker_compose": "false"}, with_hooks=True) + assert not (project / "docker-compose.local.yml").exists() + assert not (project / "deploy").exists() + class TestConfigFiles: def test_gitignore_entries(self, bake_project): diff --git a/{{cookiecutter.app_name}}/deploy/local/grafana/dashboards/coldbrew-service.json b/{{cookiecutter.app_name}}/deploy/local/grafana/dashboards/coldbrew-service.json new file mode 100644 index 0000000..744c0dd --- /dev/null +++ b/{{cookiecutter.app_name}}/deploy/local/grafana/dashboards/coldbrew-service.json @@ -0,0 +1,291 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "RED Overview", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "mode": "none" } + }, + "unit": "reqps" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 }, + "id": 1, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } }, + "title": "Request Rate (QPS)", + "targets": [ + { + "expr": "sum by (grpc_method) (rate(grpc_server_started_total{grpc_type=\"unary\"}[$__rate_interval]))", + "legendFormat": "{{grpc_method}}" + } + ], + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "mode": "none" } + }, + "unit": "percent", + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 }, + "id": 2, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } }, + "title": "Error Rate (%)", + "targets": [ + { + "expr": "sum by (grpc_method) (rate(grpc_server_handled_total{grpc_type=\"unary\",grpc_code!=\"OK\"}[$__rate_interval])) / sum by (grpc_method) (rate(grpc_server_started_total{grpc_type=\"unary\"}[$__rate_interval])) * 100", + "legendFormat": "{{grpc_method}}" + } + ], + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "mode": "none" } + }, + "unit": "s" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 }, + "id": 3, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } }, + "title": "Latency (p50 / p95 / p99)", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(grpc_server_handling_seconds_bucket{grpc_type=\"unary\"}[$__rate_interval])))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(grpc_server_handling_seconds_bucket{grpc_type=\"unary\"}[$__rate_interval])))", + "legendFormat": "p95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(grpc_server_handling_seconds_bucket{grpc_type=\"unary\"}[$__rate_interval])))", + "legendFormat": "p99" + } + ], + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 9 }, + "id": 200, + "title": "gRPC Details", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "stacking": { "mode": "normal" } + }, + "unit": "reqps" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 10 }, + "id": 4, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean"] } }, + "title": "Status Code Distribution", + "targets": [ + { + "expr": "sum by (grpc_code) (rate(grpc_server_handled_total{grpc_type=\"unary\"}[$__rate_interval]))", + "legendFormat": "{{grpc_code}}" + } + ], + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "mode": "none" } + }, + "unit": "s" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 10 }, + "id": 5, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } }, + "title": "p95 Latency by Method", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (grpc_method, le) (rate(grpc_server_handling_seconds_bucket{grpc_type=\"unary\"}[$__rate_interval])))", + "legendFormat": "{{grpc_method}}" + } + ], + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 18 }, + "id": 300, + "title": "Go Runtime", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 19 }, + "id": 6, + "title": "Goroutines", + "targets": [ + { + "expr": "go_goroutines", + "legendFormat": "goroutines" + } + ], + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "bytes" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 19 }, + "id": 7, + "title": "Heap Usage", + "targets": [ + { + "expr": "go_memstats_alloc_bytes", + "legendFormat": "alloc" + }, + { + "expr": "go_memstats_sys_bytes", + "legendFormat": "sys" + } + ], + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 19 }, + "id": 8, + "title": "GC Pause Duration", + "targets": [ + { + "expr": "rate(go_gc_duration_seconds_sum[$__rate_interval])", + "legendFormat": "gc time/sec" + } + ], + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": ["coldbrew", "grpc", "go"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "ColdBrew Service", + "uid": "coldbrew-service", + "version": 1 +} diff --git a/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/dashboards/dashboards.yml b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..546e6e7 --- /dev/null +++ b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,9 @@ +apiVersion: 1 +providers: + - name: ColdBrew + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..4e6c2e9 --- /dev/null +++ b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,7 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/{{cookiecutter.app_name}}/deploy/prometheus.yml b/{{cookiecutter.app_name}}/deploy/local/prometheus.yml similarity index 100% rename from {{cookiecutter.app_name}}/deploy/prometheus.yml rename to {{cookiecutter.app_name}}/deploy/local/prometheus.yml diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index fd80c8a..b3344d3 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -39,7 +39,7 @@ services: ports: - "9100:9090" volumes: - - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml + - ./deploy/local/prometheus.yml:/etc/prometheus/prometheus.yml grafana: image: grafana/grafana:latest @@ -49,3 +49,6 @@ services: - "3000:3000" environment: GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - ./deploy/local/grafana/provisioning:/etc/grafana/provisioning + - ./deploy/local/grafana/dashboards:/var/lib/grafana/dashboards From c686cab7de5b566743a93b7f4a5b4b05b902ac6e Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 18:19:12 +0800 Subject: [PATCH 04/17] feat: infra-only local-stack, load testing, dynamic endpoint display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove app from docker-compose (use make run instead — faster native build) - Prometheus scrapes host.docker.internal:9091 (app runs on host) - local-stack prints running container endpoints after startup - Add ghz load test config (misc/loadtest/echo.json) with gRPC reflection - make loadtest target runs 10s load test at concurrency 10 - Update README.md and AGENTS.md with local stack and load testing docs - 53 tests pass --- tests/test_cookiecutter_generation.py | 17 +++++++-- {{cookiecutter.app_name}}/AGENTS.md | 38 ++++++++++++++----- {{cookiecutter.app_name}}/Makefile | 18 +++++++-- {{cookiecutter.app_name}}/README.md | 27 +++++++++++++ .../deploy/local/prometheus.yml | 2 +- .../docker-compose.local.yml | 9 ----- 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 19621ca..41e23b1 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -69,6 +69,7 @@ def test_expected_files_exist(self, bake_project): "deploy/local/grafana/provisioning/datasources/prometheus.yml", "deploy/local/grafana/provisioning/dashboards/dashboards.yml", "deploy/local/grafana/dashboards/coldbrew-service.json", + "misc/loadtest/echo.json", "go.mod", "README.md", "AGENTS.md", @@ -362,12 +363,13 @@ def test_gitlab_ci_go_tool_cobertura(self, bake_project): class TestDockerCompose: - def test_compose_service_name(self, bake_project): + def test_compose_infra_services(self, bake_project): project = bake_project() content = (project / "docker-compose.local.yml").read_text() - assert "testservice:" in content - assert "9090:9090" in content - assert "9091:9091" in content + assert "db:" in content + assert "redis:" in content + assert "prometheus:" in content + assert "grafana:" in content def test_compose_profiles(self, bake_project): project = bake_project() @@ -399,6 +401,13 @@ def test_grafana_volumes_in_compose(self, bake_project): assert "deploy/local/grafana/provisioning:/etc/grafana/provisioning" in content assert "deploy/local/grafana/dashboards:/var/lib/grafana/dashboards" in content + def test_loadtest_config(self, bake_project): + project = bake_project() + content = (project / "misc/loadtest/echo.json").read_text() + assert "com.github.testorg.TestSvc/Echo" in content + assert '"reflect": true' in content + assert "localhost:9090" in content + def test_docker_compose_disabled(self, bake_project): project = bake_project({"include_docker_compose": "false"}, with_hooks=True) assert not (project / "docker-compose.local.yml").exists() diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index cbae656..d3a255c 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -105,26 +105,44 @@ GOPRIVATE is pre-configured in Makefile, Dockerfile, and CI workflows. For priva ## Local Development Stack -Start the service and dependencies with docker-compose: +Start infrastructure with docker-compose, then run the app locally with `make run`: ```bash -make local-stack # service only -make local-stack PROFILES="deps" # + Postgres, Redis, Adminer -make local-stack PROFILES="deps obs" # + Prometheus, Grafana -make local-stack-logs # follow logs -make local-stack-down # stop stack -make local-stack-reset # stop, remove, restart +# Start infrastructure +make local-stack PROFILES="deps" # Postgres, Redis, Adminer +make local-stack PROFILES="deps obs" # + Prometheus, Grafana (with pre-built dashboard) + +# Run the app (fast native build, no Docker) +make run + +# Infrastructure management +make local-stack-logs # follow infra logs +make local-stack-down PROFILES="deps" # stop infra +make local-stack-reset PROFILES="deps" # stop, remove, restart make local-psql # open Postgres shell ``` -Endpoints when running with all profiles: -- Service HTTP/Swagger: http://localhost:9091/swagger/ +Endpoints: +- Service HTTP/Swagger: http://localhost:9091/swagger/ (via `make run`) - Service gRPC: localhost:9090 - Postgres: localhost:5433 (user: postgres, password: postgres, db: {{cookiecutter.app_name}}_dev) - Redis: localhost:6379 - Adminer (DB UI): http://localhost:8088 - Prometheus: http://localhost:9100 -- Grafana: http://localhost:3000 (admin/admin) +- Grafana: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded + +## Load Testing + +Run gRPC load tests against a locally running service using [ghz](https://ghz.sh): + +```bash +make run # start the app in one terminal +make loadtest # run load test in another terminal +``` + +The default config (`misc/loadtest/echo.json`) sends 1000 requests at concurrency 10 to the Echo RPC via gRPC reflection. Edit the file to adjust total, concurrency, or target a different RPC. + +With the obs profile running (`make local-stack PROFILES="deps obs"`), load test results are visible in the Grafana dashboard in real-time. ## Rules diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 964a893..d231afe 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-rm local-stack-reset local-psql +.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-rm local-stack-reset local-psql loadtest BIN_NAME={{cookiecutter.app_name}} GOPRIVATE ?= {{cookiecutter.goprivate}} @@ -35,6 +35,7 @@ help: @echo ' make run-docker Run the project in a docker container.' @echo ' make runj Run the project locally with jq log parsing.' @echo ' make test Run tests.' + @echo ' make loadtest Run gRPC load test (ghz) against the running service.' @echo ' make local-stack Start local dev stack (PROFILES="deps" for db/redis, "deps obs" for full).' @echo ' make local-stack-down Stop local dev stack.' @echo ' make local-stack-logs Follow local dev stack logs.' @@ -108,8 +109,7 @@ run-docker: build-docker # Local development stack (docker-compose) # Usage: -# make local-stack # service only -# make local-stack PROFILES="deps" # + postgres, redis, adminer +# make local-stack PROFILES="deps" # postgres, redis, adminer # make local-stack PROFILES="deps obs" # + prometheus, grafana # make local-stack-down PROFILES="deps" # tear down with deps @@ -118,6 +118,15 @@ PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) local-stack: docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait + @echo + @echo "Local stack is running. Run 'make run' to start the app." + @echo "Endpoints:" + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port db 5432 2>/dev/null | sed 's|.*:\(.*\)| Postgres: localhost:\1|' || true + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true + @echo local-stack-down: docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) down @@ -132,3 +141,6 @@ local-stack-reset: local-stack-down local-stack-rm local-stack local-psql: docker exec -ti {{cookiecutter.app_name}}-db-1 bash -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' + +loadtest: + ghz --config misc/loadtest/echo.json diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index c787834..0ab782f 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -25,9 +25,36 @@ The Makefile contains a number of useful commands to help you get started. Here - `make lint` - Runs the linter - `make run` - Runs the application - `make runj` - Runs the application with json logs parsing with jq +- `make loadtest` - Runs gRPC load test ([ghz](https://ghz.sh)) against the running service - `make build` - Builds the application - `make generate` - Generates the code +## Local Development Stack + +Start infrastructure dependencies with docker-compose, then run the app natively: + +```console +$ make local-stack PROFILES="deps" # Start Postgres, Redis, Adminer +$ make local-stack PROFILES="deps obs" # + Prometheus, Grafana (with pre-built dashboard) +$ make run # Run the app (fast native build) +``` + +Infrastructure management: + +```console +$ make local-stack-down PROFILES="deps" # Stop infrastructure +$ make local-stack-reset PROFILES="deps" # Reset infrastructure +$ make local-psql # Open Postgres shell +``` + +Endpoints when running with all profiles: +- **Swagger UI**: http://localhost:9091/swagger/ +- **Grafana**: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded +- **Prometheus**: http://localhost:9100 +- **Adminer**: http://localhost:8088 +- **Postgres**: localhost:5433 +- **Redis**: localhost:6379 + ## Docker This project also contains a Dockerfile to help you get started with Docker. To build the image, run: diff --git a/{{cookiecutter.app_name}}/deploy/local/prometheus.yml b/{{cookiecutter.app_name}}/deploy/local/prometheus.yml index 10be11d..0e9a8ca 100644 --- a/{{cookiecutter.app_name}}/deploy/local/prometheus.yml +++ b/{{cookiecutter.app_name}}/deploy/local/prometheus.yml @@ -4,4 +4,4 @@ global: scrape_configs: - job_name: {{cookiecutter.app_name}} static_configs: - - targets: ["{{cookiecutter.app_name}}:9091"] + - targets: ["host.docker.internal:9091"] diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index b3344d3..d015138 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -1,13 +1,4 @@ services: - {{cookiecutter.app_name}}: - build: . - restart: always - ports: - - "9090:9090" - - "9091:9091" - env_file: - - local.env - db: image: postgres:17-alpine restart: always From 3dbf8e6d9ae9aef08aa2c3e40ac34d39a4b92a7b Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 18:20:47 +0800 Subject: [PATCH 05/17] fix: stop gitignoring misc/, add loadtest config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit misc/ contained generated mocks but also now has loadtest configs. Neither should be gitignored — mocks are useful to commit for CI. --- {{cookiecutter.app_name}}/.gitignore | 1 - {{cookiecutter.app_name}}/misc/loadtest/echo.json | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 {{cookiecutter.app_name}}/misc/loadtest/echo.json diff --git a/{{cookiecutter.app_name}}/.gitignore b/{{cookiecutter.app_name}}/.gitignore index a7f7eb8..defb5d1 100644 --- a/{{cookiecutter.app_name}}/.gitignore +++ b/{{cookiecutter.app_name}}/.gitignore @@ -3,4 +3,3 @@ vendor cover.out cover.html local.env -misc diff --git a/{{cookiecutter.app_name}}/misc/loadtest/echo.json b/{{cookiecutter.app_name}}/misc/loadtest/echo.json new file mode 100644 index 0000000..b13b2c8 --- /dev/null +++ b/{{cookiecutter.app_name}}/misc/loadtest/echo.json @@ -0,0 +1,11 @@ +{ + "call": "{{cookiecutter.grpc_package}}.{{cookiecutter.service_name}}/Echo", + "host": "localhost:9090", + "insecure": true, + "reflect": true, + "data": { + "msg": "hello" + }, + "duration": "10s", + "concurrency": 10 +} From c0c6e0bb03941a641a5a4e35f485d408b5848235 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 18:34:08 +0800 Subject: [PATCH 06/17] fix: add --remove-orphans to local-stack, fix gitignore test --- tests/test_cookiecutter_generation.py | 2 +- {{cookiecutter.app_name}}/Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 41e23b1..d789616 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -418,7 +418,7 @@ class TestConfigFiles: def test_gitignore_entries(self, bake_project): project = bake_project() content = (project / ".gitignore").read_text() - for entry in ["local.env", "cover.html", "cover.out", "misc"]: + for entry in ["local.env", "cover.html", "cover.out"]: assert entry in content def test_local_env_example_exists(self, bake_project): diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index d231afe..9a081e3 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -117,7 +117,7 @@ COMPOSE_FILE := docker-compose.local.yml PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) local-stack: - docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait + docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait --remove-orphans @echo @echo "Local stack is running. Run 'make run' to start the app." @echo "Endpoints:" From a8110cc4482ae1dad56c405134acf8ea7265e839 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 19:15:45 +0800 Subject: [PATCH 07/17] feat: add metrics package with interface, promauto, and sample usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service/metrics/ package: Metrics interface, promauto implementation, label constants (types.go, metrics.go, labels.go, metrics_test.go) - Sample metrics: echo_total counter, echo_duration_seconds histogram, active_requests gauge — shows counter/histogram/gauge patterns - Uses seconds for durations (Prometheus convention) - Wired into Echo handler with defer pattern for timing + outcome - Interface enables mocking via mockery (already configured) - 56 tests pass --- tests/test_cookiecutter_generation.py | 24 ++++++++ .../service/metrics/labels.go | 7 +++ .../service/metrics/metrics.go | 50 +++++++++++++++ .../service/metrics/metrics_test.go | 61 +++++++++++++++++++ .../service/metrics/types.go | 17 ++++++ {{cookiecutter.app_name}}/service/service.go | 17 +++++- 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 {{cookiecutter.app_name}}/service/metrics/labels.go create mode 100644 {{cookiecutter.app_name}}/service/metrics/metrics.go create mode 100644 {{cookiecutter.app_name}}/service/metrics/metrics_test.go create mode 100644 {{cookiecutter.app_name}}/service/metrics/types.go diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index d789616..6235525 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -87,6 +87,10 @@ def test_expected_files_exist(self, bake_project): "service/healthcheck.go", "service/service_test.go", "service/healthcheck_test.go", + "service/metrics/types.go", + "service/metrics/metrics.go", + "service/metrics/labels.go", + "service/metrics/metrics_test.go", "version/version.go", ".github/workflows/go.yml", ".gitlab-ci.yml", @@ -177,6 +181,26 @@ def test_service_formatting(self, bake_project): assert "func (s *svc) Stop()" in content assert "func (s*svc)" not in content + def test_metrics_interface(self, bake_project): + project = bake_project() + content = (project / "service/metrics/types.go").read_text() + assert "type Metrics interface" in content + assert "IncEchoTotal" in content + assert "ObserveEchoDuration" in content + + def test_metrics_uses_promauto(self, bake_project): + project = bake_project() + content = (project / "service/metrics/metrics.go").read_text() + assert "promauto" in content + assert "_duration_seconds" in content + assert '_namespace = "testservice"' in content or 'namespace = "testservice"' in content + + def test_service_wires_metrics(self, bake_project): + project = bake_project() + content = (project / "service/service.go").read_text() + assert "metrics.New()" in content + assert "monitoring metrics.Metrics" in content + def test_version_app_name(self, bake_project): project = bake_project() content = (project / "version/version.go").read_text() diff --git a/{{cookiecutter.app_name}}/service/metrics/labels.go b/{{cookiecutter.app_name}}/service/metrics/labels.go new file mode 100644 index 0000000..c5ded17 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/metrics/labels.go @@ -0,0 +1,7 @@ +package metrics + +// Outcome label values for use with IncEchoTotal and similar methods. +const ( + OutcomeSuccess = "success" + OutcomeError = "error" +) diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics.go b/{{cookiecutter.app_name}}/service/metrics/metrics.go new file mode 100644 index 0000000..a3550c2 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/metrics/metrics.go @@ -0,0 +1,50 @@ +package metrics + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const namespace = "{{cookiecutter.app_name}}" + +var ( + echoTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Name: "echo_total", + Help: "Total number of Echo RPC calls by outcome.", + }, []string{"outcome"}) + + echoDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Name: "echo_duration_seconds", + Help: "Duration of Echo RPC calls in seconds.", + Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, + }, []string{"outcome"}) + + activeRequests = promauto.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "active_requests", + Help: "Number of currently active requests.", + }) +) + +type appMetrics struct{} + +// New returns a new Metrics implementation. +func New() Metrics { + return &appMetrics{} +} + +func (m *appMetrics) IncEchoTotal(outcome string) { + echoTotal.WithLabelValues(outcome).Inc() +} + +func (m *appMetrics) ObserveEchoDuration(outcome string, duration time.Duration) { + echoDuration.WithLabelValues(outcome).Observe(duration.Seconds()) +} + +func (m *appMetrics) SetActiveRequests(count int) { + activeRequests.Set(float64(count)) +} diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics_test.go b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go new file mode 100644 index 0000000..151e6f2 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go @@ -0,0 +1,61 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func gatherMetric(name string) *dto.MetricFamily { + families, _ := prometheus.DefaultGatherer.Gather() + for _, f := range families { + if f.GetName() == name { + return f + } + } + return nil +} + +func TestIncEchoTotal(t *testing.T) { + m := New() + m.IncEchoTotal(OutcomeSuccess) + m.IncEchoTotal(OutcomeError) + + mf := gatherMetric(namespace + "_echo_total") + if mf == nil { + t.Fatal("metric not found") + } + if len(mf.GetMetric()) < 2 { + t.Fatalf("expected at least 2 label pairs, got %d", len(mf.GetMetric())) + } +} + +func TestObserveEchoDuration(t *testing.T) { + m := New() + m.ObserveEchoDuration(OutcomeSuccess, 50*time.Millisecond) + + mf := gatherMetric(namespace + "_echo_duration_seconds") + if mf == nil { + t.Fatal("metric not found") + } + h := mf.GetMetric()[0].GetHistogram() + if h.GetSampleCount() == 0 { + t.Fatal("expected at least one observation") + } +} + +func TestSetActiveRequests(t *testing.T) { + m := New() + m.SetActiveRequests(5) + + mf := gatherMetric(namespace + "_active_requests") + if mf == nil { + t.Fatal("metric not found") + } + val := mf.GetMetric()[0].GetGauge().GetValue() + if val != 5 { + t.Fatalf("expected 5, got %f", val) + } +} diff --git a/{{cookiecutter.app_name}}/service/metrics/types.go b/{{cookiecutter.app_name}}/service/metrics/types.go new file mode 100644 index 0000000..05ceabc --- /dev/null +++ b/{{cookiecutter.app_name}}/service/metrics/types.go @@ -0,0 +1,17 @@ +package metrics + +import "time" + +// Metrics defines the application metrics interface. +// All methods are safe for concurrent use. +// +// Add new methods here as your service grows. The interface +// enables mocking in tests via mockery. +type Metrics interface { + // Echo RPC metrics + IncEchoTotal(outcome string) + ObserveEchoDuration(outcome string, duration time.Duration) + + // Active requests gauge + SetActiveRequests(count int) +} diff --git a/{{cookiecutter.app_name}}/service/service.go b/{{cookiecutter.app_name}}/service/service.go index 276d712..51e6c46 100644 --- a/{{cookiecutter.app_name}}/service/service.go +++ b/{{cookiecutter.app_name}}/service/service.go @@ -3,9 +3,11 @@ package service import ( "context" "fmt" + "time" "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/config" proto "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/proto" + "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/service/metrics" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/genproto/googleapis/api/httpbody" "github.com/go-coldbrew/errors" @@ -20,6 +22,8 @@ var _ proto.{{cookiecutter.service_name}}Server = (*svc)(nil) type svc struct { // health server for the service *health.Server + // application metrics + monitoring metrics.Metrics // TODO: remove this, since this is just to demonstrate how to use config // prefix to be added to the message in the response prefix string @@ -39,7 +43,16 @@ func (s *svc) HealthCheck(ctx context.Context, _ *emptypb.Empty) (*httpbody.Http // Echo returns the message with the prefix added // TODO: remove this, since this is just to demonstrate how to use endpoints and config -func (s *svc) Echo(_ context.Context, req *proto.EchoRequest) (*proto.EchoResponse, error) { +func (s *svc) Echo(_ context.Context, req *proto.EchoRequest) (resp *proto.EchoResponse, err error) { + start := time.Now() + outcome := metrics.OutcomeSuccess + defer func() { + if err != nil { + outcome = metrics.OutcomeError + } + s.monitoring.IncEchoTotal(outcome) + s.monitoring.ObserveEchoDuration(outcome, time.Since(start)) + }() return &proto.EchoResponse{ Msg: fmt.Sprintf("%s: %s", s.prefix, req.GetMsg()), }, nil @@ -63,6 +76,8 @@ func New(cfg config.Config) (*svc, error) { s := &svc{ // This is the health server for the service that is used for grpc Server: GetHealthCheckServer(), + // application metrics + monitoring: metrics.New(), // TODO: remove this, since this is just to demonstrate how to use config prefix: cfg.Prefix, } From b3eb0384320f3faa295c6c78c1f14bcdae794bda Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 19:23:14 +0800 Subject: [PATCH 08/17] feat: add Jaeger to obs profile for OTEL distributed tracing - Jaeger all-in-one in obs profile (UI on :16686, OTLP on :4317) - local.env.example sets OTLP_ENDPOINT + OTLP_INSECURE for auto trace export - Traces flow to Jaeger automatically when obs profile is running - Added to Makefile endpoint display, AGENTS.md, README.md --- tests/test_cookiecutter_generation.py | 1 + {{cookiecutter.app_name}}/AGENTS.md | 1 + {{cookiecutter.app_name}}/Makefile | 1 + {{cookiecutter.app_name}}/README.md | 1 + {{cookiecutter.app_name}}/docker-compose.local.yml | 10 ++++++++++ {{cookiecutter.app_name}}/local.env.example | 3 +++ 6 files changed, 17 insertions(+) diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 6235525..19a669e 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -394,6 +394,7 @@ def test_compose_infra_services(self, bake_project): assert "redis:" in content assert "prometheus:" in content assert "grafana:" in content + assert "jaeger:" in content def test_compose_profiles(self, bake_project): project = bake_project() diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index d3a255c..c8d9c52 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -130,6 +130,7 @@ Endpoints: - Adminer (DB UI): http://localhost:8088 - Prometheus: http://localhost:9100 - Grafana: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded +- Jaeger: http://localhost:16686 — distributed traces (OTLP on :4317) ## Load Testing diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 9a081e3..5f32bdc 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -126,6 +126,7 @@ local-stack: @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true + @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true @echo local-stack-down: diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 0ab782f..5542f0d 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -50,6 +50,7 @@ $ make local-psql # Open Postgres shell Endpoints when running with all profiles: - **Swagger UI**: http://localhost:9091/swagger/ - **Grafana**: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded +- **Jaeger**: http://localhost:16686 — distributed traces - **Prometheus**: http://localhost:9100 - **Adminer**: http://localhost:8088 - **Postgres**: localhost:5433 diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index d015138..9d6683a 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -43,3 +43,13 @@ services: volumes: - ./deploy/local/grafana/provisioning:/etc/grafana/provisioning - ./deploy/local/grafana/dashboards:/var/lib/grafana/dashboards + + jaeger: + image: jaegertracing/all-in-one:latest + restart: always + profiles: ["obs"] + ports: + - "16686:16686" + - "4317:4317" + environment: + COLLECTOR_OTLP_ENABLED: "true" diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index 1135863..58ed1de 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -1 +1,4 @@ ENVIRONMENT="dev" +# OpenTelemetry tracing — traces flow to Jaeger when obs profile is running +OTLP_ENDPOINT=localhost:4317 +OTLP_INSECURE=true From 5f90a49868a100b4b7faf4bdaf2474c8336402d7 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 19:56:17 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20portable=20compose,=20rm=20cleanup,=20psql=20exec,=20YAML=20?= =?UTF-8?q?quoting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DOCKER_COMPOSE variable (default: docker-compose, override for v2 plugin) - PROFILES defaults to "deps" so bare make local-stack works - Drop local-stack-rm (down already removes containers) - local-psql uses docker-compose exec + sh (not bash, not hardcoded name) - Quote POSTGRES_DB value in compose YAML - Update help text and usage comments --- tests/test_cookiecutter_generation.py | 2 +- {{cookiecutter.app_name}}/Makefile | 40 +++++++++---------- .../docker-compose.local.yml | 4 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 19a669e..3919f8b 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -405,7 +405,7 @@ def test_compose_profiles(self, bake_project): def test_compose_db_name(self, bake_project): project = bake_project() content = (project / "docker-compose.local.yml").read_text() - assert "POSTGRES_DB: testservice_dev" in content + assert "testservice_dev" in content def test_agents_md_local_stack(self, bake_project): project = bake_project() diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 5f32bdc..993df3b 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-rm local-stack-reset local-psql loadtest +.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-reset local-psql loadtest BIN_NAME={{cookiecutter.app_name}} GOPRIVATE ?= {{cookiecutter.goprivate}} @@ -36,10 +36,10 @@ help: @echo ' make runj Run the project locally with jq log parsing.' @echo ' make test Run tests.' @echo ' make loadtest Run gRPC load test (ghz) against the running service.' - @echo ' make local-stack Start local dev stack (PROFILES="deps" for db/redis, "deps obs" for full).' + @echo ' make local-stack Start local dev stack (default: deps; PROFILES="deps obs" for full).' @echo ' make local-stack-down Stop local dev stack.' @echo ' make local-stack-logs Follow local dev stack logs.' - @echo ' make local-stack-reset Reset local dev stack (down + rm + up).' + @echo ' make local-stack-reset Reset local dev stack (down + up).' @echo ' make local-psql Open psql shell in local Postgres.' @echo @@ -109,39 +109,39 @@ run-docker: build-docker # Local development stack (docker-compose) # Usage: -# make local-stack PROFILES="deps" # postgres, redis, adminer -# make local-stack PROFILES="deps obs" # + prometheus, grafana -# make local-stack-down PROFILES="deps" # tear down with deps +# make local-stack # deps profile (postgres, redis, adminer) +# make local-stack PROFILES="deps obs" # + prometheus, grafana, jaeger +# make local-stack-down # tear down +# DOCKER_COMPOSE="docker compose" make local-stack # use Compose v2 plugin +DOCKER_COMPOSE ?= docker-compose COMPOSE_FILE := docker-compose.local.yml +PROFILES ?= deps PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) local-stack: - docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait --remove-orphans + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) up -d --wait --remove-orphans @echo @echo "Local stack is running. Run 'make run' to start the app." @echo "Endpoints:" - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port db 5432 2>/dev/null | sed 's|.*:\(.*\)| Postgres: localhost:\1|' || true - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true - @docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port db 5432 2>/dev/null | sed 's|.*:\(.*\)| Postgres: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true @echo local-stack-down: - docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) down + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) down local-stack-logs: - docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) logs -f + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) logs -f -local-stack-rm: - docker-compose -f $(COMPOSE_FILE) $(PROFILE_FLAGS) rm - -local-stack-reset: local-stack-down local-stack-rm local-stack +local-stack-reset: local-stack-down local-stack local-psql: - docker exec -ti {{cookiecutter.app_name}}-db-1 bash -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile deps exec db sh -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' loadtest: ghz --config misc/loadtest/echo.json diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index 9d6683a..28fc36f 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -6,8 +6,8 @@ services: ports: - "5433:5432" environment: - POSTGRES_DB: {{cookiecutter.app_name}}_dev - POSTGRES_PASSWORD: postgres + POSTGRES_DB: "{{cookiecutter.app_name}}_dev" + POSTGRES_PASSWORD: "postgres" redis: image: redis:8-alpine From a13436456076c336f66e37102e864771bbcce449 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 21:29:00 +0800 Subject: [PATCH 10/17] feat: per-service docker-compose profiles with inline option selection - Cookiecutter prompt shows all available services in the variable name: local_services (postgres,mysql,...,adminer) [postgres,redis]: - Per-service profiles replace the old "deps" profile (20 services) - PROFILES default in Makefile rendered from user's selection - Added: mysql, cockroachdb, mongodb, valkey, memcached, kafka (apache/kafka), nats, elasticsearch, ministack, dynamodb, spanner, pubsub, bigtable, firestore, alloy (grafana/alloy OTEL collector) - Removed bitnami/kafka (no longer free), localstack (sunset), minio (Docker images ended) - Postgres bumped to 18 - Removed pre_gen_project.py (options inline in prompt now) - 57 tests pass --- cookiecutter.json | 4 +- tests/test_cookiecutter_generation.py | 28 ++-- {{cookiecutter.app_name}}/AGENTS.md | 46 +++--- {{cookiecutter.app_name}}/Makefile | 42 +++-- {{cookiecutter.app_name}}/README.md | 25 +-- .../deploy/local/alloy/config.alloy | 21 +++ .../docker-compose.local.yml | 151 +++++++++++++++++- 7 files changed, 250 insertions(+), 67 deletions(-) create mode 100644 {{cookiecutter.app_name}}/deploy/local/alloy/config.alloy diff --git a/cookiecutter.json b/cookiecutter.json index 4c4d4c3..4e95d51 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -10,11 +10,13 @@ "docker_build_image": "golang", "docker_build_image_version": ["1.26", "1.25"], "include_docker_compose": true, + "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)": "postgres,redis", "_copy_without_render": [ "third_party/OpenAPI/swagger-ui*", "third_party/*js.map", "third_party/*css.map", "third_party/*css", - "deploy/local/grafana/*" + "deploy/local/grafana/*", + "deploy/local/alloy/*" ] } diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 3919f8b..a14d116 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -69,6 +69,7 @@ def test_expected_files_exist(self, bake_project): "deploy/local/grafana/provisioning/datasources/prometheus.yml", "deploy/local/grafana/provisioning/dashboards/dashboards.yml", "deploy/local/grafana/dashboards/coldbrew-service.json", + "deploy/local/alloy/config.alloy", "misc/loadtest/echo.json", "go.mod", "README.md", @@ -387,20 +388,27 @@ def test_gitlab_ci_go_tool_cobertura(self, bake_project): class TestDockerCompose: - def test_compose_infra_services(self, bake_project): + def test_compose_per_service_profiles(self, bake_project): project = bake_project() content = (project / "docker-compose.local.yml").read_text() - assert "db:" in content - assert "redis:" in content - assert "prometheus:" in content - assert "grafana:" in content - assert "jaeger:" in content + for svc in ["postgres", "mysql", "redis", "kafka", "nats", + "elasticsearch", "ministack", "dynamodb", "spanner", + "prometheus", "grafana", "jaeger", "alloy"]: + assert svc + ":" in content, f"missing service: {svc}" + assert 'profiles: ["obs"]' in content + assert 'profiles: ["postgres"]' in content + assert 'profiles: ["kafka"]' in content - def test_compose_profiles(self, bake_project): + def test_default_profiles_in_makefile(self, bake_project): project = bake_project() - content = (project / "docker-compose.local.yml").read_text() - assert 'profiles: ["deps"]' in content - assert 'profiles: ["obs"]' in content + content = (project / "Makefile").read_text() + assert "PROFILES ?= postgres redis" in content + + def test_custom_local_services(self, bake_project): + long_key = "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)" + project = bake_project({long_key: "postgres,kafka,nats"}) + content = (project / "Makefile").read_text() + assert "PROFILES ?= postgres kafka nats" in content def test_compose_db_name(self, bake_project): project = bake_project() diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index c8d9c52..f6b3b22 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -108,29 +108,35 @@ GOPRIVATE is pre-configured in Makefile, Dockerfile, and CI workflows. For priva Start infrastructure with docker-compose, then run the app locally with `make run`: ```bash -# Start infrastructure -make local-stack PROFILES="deps" # Postgres, Redis, Adminer -make local-stack PROFILES="deps obs" # + Prometheus, Grafana (with pre-built dashboard) - -# Run the app (fast native build, no Docker) -make run - -# Infrastructure management -make local-stack-logs # follow infra logs -make local-stack-down PROFILES="deps" # stop infra -make local-stack-reset PROFILES="deps" # stop, remove, restart -make local-psql # open Postgres shell +make local-stack # start default services (selected during generation) +make local-stack PROFILES="postgres kafka obs" # override with specific services +make run # run the app (fast native build, no Docker) +make local-stack-down # stop infra +make local-psql # open Postgres shell ``` -Endpoints: -- Service HTTP/Swagger: http://localhost:9091/swagger/ (via `make run`) -- Service gRPC: localhost:9090 -- Postgres: localhost:5433 (user: postgres, password: postgres, db: {{cookiecutter.app_name}}_dev) -- Redis: localhost:6379 -- Adminer (DB UI): http://localhost:8088 -- Prometheus: http://localhost:9100 +Available profiles: + +| Category | Profiles | +|----------|----------| +| Databases | `postgres`, `mysql`, `cockroachdb`, `mongodb` | +| Cache | `redis`, `valkey`, `memcached` | +| Messaging | `kafka`, `nats` | +| Search | `elasticsearch` | +| AWS | `ministack`, `dynamodb` | +| GCP | `spanner`, `pubsub`, `bigtable`, `firestore` | +| Tools | `adminer` | +| Observability | `obs` (Prometheus, Grafana, Jaeger, Alloy) | + +Service endpoints (via `make run`): +- HTTP/Swagger: http://localhost:9091/swagger/ +- gRPC: localhost:9090 + +Obs endpoints (when running with `obs` profile): - Grafana: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded -- Jaeger: http://localhost:16686 — distributed traces (OTLP on :4317) +- Jaeger: http://localhost:16686 — distributed traces +- Prometheus: http://localhost:9100 +- Alloy: http://localhost:12345 — OTEL collector UI ## Load Testing diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 993df3b..6eb0ba4 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -36,10 +36,10 @@ help: @echo ' make runj Run the project locally with jq log parsing.' @echo ' make test Run tests.' @echo ' make loadtest Run gRPC load test (ghz) against the running service.' - @echo ' make local-stack Start local dev stack (default: deps; PROFILES="deps obs" for full).' + @echo ' make local-stack Start local dev stack (override with PROFILES="postgres kafka obs").' @echo ' make local-stack-down Stop local dev stack.' @echo ' make local-stack-logs Follow local dev stack logs.' - @echo ' make local-stack-reset Reset local dev stack (down + up).' + @echo ' make local-stack-reset Reset local dev stack.' @echo ' make local-psql Open psql shell in local Postgres.' @echo @@ -108,15 +108,18 @@ run-docker: build-docker docker run -p 9091:9091 -p 9090:9090 --env-file local.env ${IMAGE_NAME}:local # Local development stack (docker-compose) +# Available profiles: postgres, mysql, cockroachdb, mongodb, redis, valkey, +# memcached, kafka, nats, elasticsearch, ministack, dynamodb, spanner, +# pubsub, bigtable, firestore, adminer, obs # Usage: -# make local-stack # deps profile (postgres, redis, adminer) -# make local-stack PROFILES="deps obs" # + prometheus, grafana, jaeger -# make local-stack-down # tear down -# DOCKER_COMPOSE="docker compose" make local-stack # use Compose v2 plugin +# make local-stack # start selected services +# make local-stack PROFILES="postgres kafka obs" # override profiles +# make local-stack-down # tear down +# DOCKER_COMPOSE="docker compose" make local-stack # use Compose v2 plugin DOCKER_COMPOSE ?= docker-compose COMPOSE_FILE := docker-compose.local.yml -PROFILES ?= deps +PROFILES ?= {{ cookiecutter['local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)'] | replace(',', ' ') }} PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) local-stack: @@ -124,12 +127,23 @@ local-stack: @echo @echo "Local stack is running. Run 'make run' to start the app." @echo "Endpoints:" - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port db 5432 2>/dev/null | sed 's|.*:\(.*\)| Postgres: localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port postgres 5432 2>/dev/null | sed 's|.*:\(.*\)| Postgres: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port mysql 3306 2>/dev/null | sed 's|.*:\(.*\)| MySQL: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port cockroachdb 26257 2>/dev/null | sed 's|.*:\(.*\)| CockroachDB: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port mongodb 27017 2>/dev/null | sed 's|.*:\(.*\)| MongoDB: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port valkey 6379 2>/dev/null | sed 's|.*:\(.*\)| Valkey: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port memcached 11211 2>/dev/null | sed 's|.*:\(.*\)| Memcached: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port kafka 9092 2>/dev/null | sed 's|.*:\(.*\)| Kafka: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port nats 4222 2>/dev/null | sed 's|.*:\(.*\)| NATS: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port elasticsearch 9200 2>/dev/null | sed 's|.*:\(.*\)| Elasticsearch: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port ministack 4566 2>/dev/null | sed 's|.*:\(.*\)| MiniStack: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port dynamodb 8000 2>/dev/null | sed 's|.*:\(.*\)| DynamoDB: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port alloy 12345 2>/dev/null | sed 's|.*:\(.*\)| Alloy: http://localhost:\1|' || true @echo local-stack-down: @@ -141,7 +155,7 @@ local-stack-logs: local-stack-reset: local-stack-down local-stack local-psql: - $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile deps exec db sh -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile postgres exec postgres sh -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' loadtest: ghz --config misc/loadtest/echo.json diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 5542f0d..82a705c 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -31,31 +31,22 @@ The Makefile contains a number of useful commands to help you get started. Here ## Local Development Stack -Start infrastructure dependencies with docker-compose, then run the app natively: +Start infrastructure with docker-compose, then run the app natively: ```console -$ make local-stack PROFILES="deps" # Start Postgres, Redis, Adminer -$ make local-stack PROFILES="deps obs" # + Prometheus, Grafana (with pre-built dashboard) -$ make run # Run the app (fast native build) +$ make local-stack # Start default services +$ make local-stack PROFILES="postgres kafka obs" # Override with specific services +$ make run # Run the app (fast native build) ``` -Infrastructure management: +Available profiles: `postgres`, `mysql`, `cockroachdb`, `mongodb`, `redis`, `valkey`, `memcached`, `kafka`, `nats`, `elasticsearch`, `ministack`, `dynamodb`, `spanner`, `pubsub`, `bigtable`, `firestore`, `adminer`, `obs` ```console -$ make local-stack-down PROFILES="deps" # Stop infrastructure -$ make local-stack-reset PROFILES="deps" # Reset infrastructure -$ make local-psql # Open Postgres shell +$ make local-stack-down # Stop infrastructure +$ make local-stack-reset # Reset infrastructure +$ make local-psql # Open Postgres shell ``` -Endpoints when running with all profiles: -- **Swagger UI**: http://localhost:9091/swagger/ -- **Grafana**: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded -- **Jaeger**: http://localhost:16686 — distributed traces -- **Prometheus**: http://localhost:9100 -- **Adminer**: http://localhost:8088 -- **Postgres**: localhost:5433 -- **Redis**: localhost:6379 - ## Docker This project also contains a Dockerfile to help you get started with Docker. To build the image, run: diff --git a/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy b/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy new file mode 100644 index 0000000..75e144b --- /dev/null +++ b/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy @@ -0,0 +1,21 @@ +// Grafana Alloy configuration for local development +// Receives OTLP telemetry and forwards to Prometheus + Jaeger + +otelcol.receiver.otlp "default" { + grpc { + endpoint = "0.0.0.0:4318" + } + + output { + traces = [otelcol.exporter.otlp.jaeger.input] + } +} + +otelcol.exporter.otlp "jaeger" { + client { + endpoint = "jaeger:4317" + tls { + insecure = true + } + } +} diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index 28fc36f..01f6a34 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -1,28 +1,158 @@ services: - db: - image: postgres:17-alpine + # --- Databases --- + postgres: + image: postgres:18-alpine restart: always - profiles: ["deps"] + profiles: ["postgres"] ports: - "5433:5432" environment: POSTGRES_DB: "{{cookiecutter.app_name}}_dev" POSTGRES_PASSWORD: "postgres" + mysql: + image: mysql:8 + restart: always + profiles: ["mysql"] + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: "root" + MYSQL_DATABASE: "{{cookiecutter.app_name}}_dev" + + cockroachdb: + image: cockroachdb/cockroach:latest + restart: always + profiles: ["cockroachdb"] + command: start-single-node --insecure + ports: + - "26257:26257" + - "8081:8080" + + mongodb: + image: mongo:7 + restart: always + profiles: ["mongodb"] + ports: + - "27017:27017" + + # --- Cache --- redis: image: redis:8-alpine restart: always - profiles: ["deps"] + profiles: ["redis"] + ports: + - "6379:6379" + + valkey: + image: valkey/valkey:8-alpine + restart: always + profiles: ["valkey"] ports: - "6379:6379" + memcached: + image: memcached:alpine + restart: always + profiles: ["memcached"] + ports: + - "11211:11211" + + # --- Messaging --- + kafka: + image: apache/kafka:latest + restart: always + profiles: ["kafka"] + ports: + - "9092:9092" + environment: + KAFKA_NODE_ID: "1" + KAFKA_PROCESS_ROLES: "broker,controller" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" + KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" + KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://localhost:9092" + KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" + CLUSTER_ID: "coldbrew-local-dev" + + nats: + image: nats:alpine + restart: always + profiles: ["nats"] + ports: + - "4222:4222" + - "8222:8222" + command: --jetstream --http_port 8222 + + # --- Search --- + elasticsearch: + image: elasticsearch:8.17.0 + restart: always + profiles: ["elasticsearch"] + ports: + - "9200:9200" + environment: + discovery.type: "single-node" + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + + # --- AWS --- + ministack: + image: ministack/ministack:latest + restart: always + profiles: ["ministack"] + ports: + - "4566:4566" + + dynamodb: + image: amazon/dynamodb-local:latest + restart: always + profiles: ["dynamodb"] + ports: + - "8000:8000" + + # --- GCP --- + spanner: + image: gcr.io/cloud-spanner-emulator/emulator:latest + restart: always + profiles: ["spanner"] + ports: + - "9010:9010" + - "9020:9020" + + pubsub: + image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators + restart: always + profiles: ["pubsub"] + command: gcloud beta emulators pubsub start --host-port=0.0.0.0:8085 + ports: + - "8085:8085" + + bigtable: + image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators + restart: always + profiles: ["bigtable"] + command: gcloud beta emulators bigtable start --host-port=0.0.0.0:8086 + ports: + - "8086:8086" + + firestore: + image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators + restart: always + profiles: ["firestore"] + command: gcloud beta emulators firestore start --host-port=0.0.0.0:8080 + ports: + - "8080:8080" + + # --- Tools --- adminer: image: adminer restart: always - profiles: ["deps"] + profiles: ["adminer"] ports: - "8088:8080" + # --- Observability --- prometheus: image: prom/prometheus:latest restart: always @@ -53,3 +183,14 @@ services: - "4317:4317" environment: COLLECTOR_OTLP_ENABLED: "true" + + alloy: + image: grafana/alloy:latest + restart: always + profiles: ["obs"] + ports: + - "12345:12345" + - "4318:4318" + volumes: + - ./deploy/local/alloy/config.alloy:/etc/alloy/config.alloy + command: run /etc/alloy/config.alloy From 2829222805055e89052a5e9c8742a062e7d52d12 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 22:10:45 +0800 Subject: [PATCH 11/17] fix: AlloyDB, local-exec, obs shortcut, code review fixes - Replace Grafana Alloy with AlloyDB Omni (GCP PostgreSQL-compatible DB) - Remove SetActiveRequests gauge (counter + histogram suffice as examples) - Add make local-stack-obs shortcut for obs profile discoverability - Replace local-psql with generic local-exec SVC=... CMD=... - Add input guards for SVC/CMD on local-exec - local-stack-down uses plain down (stops all containers regardless of profile) - Fix valkey port to 6380 (avoids conflict with redis on 6379) - Add missing endpoint display for spanner, pubsub, bigtable, firestore, alloydb - Remove SetReady() from TestEcho (not needed, avoids global state leak) - Service tests use mockmetrics for proper unit testing with expectations - Reorder post-gen hook: mock before tidy (mocks must exist for import resolution) - Add ordering comment to init_proto explaining step dependencies - 58 tests pass --- cookiecutter.json | 5 ++- hooks/post_gen_project.py | 14 ++++---- tests/test_cookiecutter_generation.py | 14 +++++--- {{cookiecutter.app_name}}/AGENTS.md | 8 ++--- {{cookiecutter.app_name}}/Makefile | 32 +++++++++++++------ {{cookiecutter.app_name}}/README.md | 2 +- .../deploy/local/alloy/config.alloy | 21 ------------ .../docker-compose.local.yml | 23 +++++++------ .../service/metrics/metrics.go | 10 ------ .../service/metrics/metrics_test.go | 14 -------- .../service/metrics/types.go | 3 -- .../service/service_test.go | 16 +++++++--- 12 files changed, 70 insertions(+), 92 deletions(-) delete mode 100644 {{cookiecutter.app_name}}/deploy/local/alloy/config.alloy diff --git a/cookiecutter.json b/cookiecutter.json index 4e95d51..092e92c 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -10,13 +10,12 @@ "docker_build_image": "golang", "docker_build_image_version": ["1.26", "1.25"], "include_docker_compose": true, - "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)": "postgres,redis", + "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,alloydb,adminer)": "postgres,redis", "_copy_without_render": [ "third_party/OpenAPI/swagger-ui*", "third_party/*js.map", "third_party/*css.map", "third_party/*css", - "deploy/local/grafana/*", - "deploy/local/alloy/*" + "deploy/local/grafana/*" ] } diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 52857b8..8d6722d 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -36,6 +36,8 @@ def init_git(): git.wait() def init_proto(): + # Order matters: download → generate (needs buf plugins) → mock (needs generated + # interfaces) → tidy (needs all imports including generated mocks to resolve) print("Starting proto initialization...") print("Step 1/4: Fetching Go modules (this might take a few minutes)...") code = Popen(["go", "mod", "download", "all"], cwd=PROJECT_DIRECTORY).wait() @@ -49,16 +51,16 @@ def init_proto(): print("Error: 'make generate' failed.") sys.exit(code) - print("Step 3/4: Tidying Go modules...") - code = Popen(["go", "mod", "tidy"], cwd=PROJECT_DIRECTORY).wait() + print("Step 3/4: Running 'make mock'...") + code = Popen(["make", "mock"], cwd=PROJECT_DIRECTORY).wait() if code != 0: - print("Error: 'go mod tidy' failed.") + print("Error: 'make mock' failed.") sys.exit(code) - print("Step 4/4: Running 'make mock'...") - code = Popen(["make", "mock"], cwd=PROJECT_DIRECTORY).wait() + print("Step 4/4: Tidying Go modules...") + code = Popen(["go", "mod", "tidy"], cwd=PROJECT_DIRECTORY).wait() if code != 0: - print("Error: 'make mock' failed.") + print("Error: 'go mod tidy' failed.") sys.exit(code) print("Proto initialization completed successfully.") diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index a14d116..0a676c9 100644 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -69,7 +69,6 @@ def test_expected_files_exist(self, bake_project): "deploy/local/grafana/provisioning/datasources/prometheus.yml", "deploy/local/grafana/provisioning/dashboards/dashboards.yml", "deploy/local/grafana/dashboards/coldbrew-service.json", - "deploy/local/alloy/config.alloy", "misc/loadtest/echo.json", "go.mod", "README.md", @@ -202,6 +201,13 @@ def test_service_wires_metrics(self, bake_project): assert "metrics.New()" in content assert "monitoring metrics.Metrics" in content + def test_service_test_uses_mock_metrics(self, bake_project): + project = bake_project() + content = (project / "service/service_test.go").read_text() + assert "mockmetrics" in content + assert "mock.AnythingOfType" in content + assert "EXPECT().IncEchoTotal" in content + def test_version_app_name(self, bake_project): project = bake_project() content = (project / "version/version.go").read_text() @@ -315,7 +321,7 @@ def test_local_stack_targets(self, bake_project): assert "local-stack-down:" in content assert "local-stack-logs:" in content assert "local-stack-reset:" in content - assert "local-psql:" in content + assert "local-exec:" in content assert "docker-compose.local.yml" in content def test_bench_run_pattern(self, bake_project): @@ -393,7 +399,7 @@ def test_compose_per_service_profiles(self, bake_project): content = (project / "docker-compose.local.yml").read_text() for svc in ["postgres", "mysql", "redis", "kafka", "nats", "elasticsearch", "ministack", "dynamodb", "spanner", - "prometheus", "grafana", "jaeger", "alloy"]: + "alloydb", "prometheus", "grafana", "jaeger"]: assert svc + ":" in content, f"missing service: {svc}" assert 'profiles: ["obs"]' in content assert 'profiles: ["postgres"]' in content @@ -405,7 +411,7 @@ def test_default_profiles_in_makefile(self, bake_project): assert "PROFILES ?= postgres redis" in content def test_custom_local_services(self, bake_project): - long_key = "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)" + long_key = "local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,alloydb,adminer)" project = bake_project({long_key: "postgres,kafka,nats"}) content = (project / "Makefile").read_text() assert "PROFILES ?= postgres kafka nats" in content diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index f6b3b22..80fc31a 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -112,7 +112,8 @@ make local-stack # start default services (selecte make local-stack PROFILES="postgres kafka obs" # override with specific services make run # run the app (fast native build, no Docker) make local-stack-down # stop infra -make local-psql # open Postgres shell +make local-exec SVC=postgres CMD="psql -U postgres" # exec into a service +make local-exec SVC=redis CMD="redis-cli" # works with any service ``` Available profiles: @@ -124,9 +125,9 @@ Available profiles: | Messaging | `kafka`, `nats` | | Search | `elasticsearch` | | AWS | `ministack`, `dynamodb` | -| GCP | `spanner`, `pubsub`, `bigtable`, `firestore` | +| GCP | `spanner`, `pubsub`, `bigtable`, `firestore`, `alloydb` | | Tools | `adminer` | -| Observability | `obs` (Prometheus, Grafana, Jaeger, Alloy) | +| Observability | `obs` (Prometheus, Grafana, Jaeger) | Service endpoints (via `make run`): - HTTP/Swagger: http://localhost:9091/swagger/ @@ -136,7 +137,6 @@ Obs endpoints (when running with `obs` profile): - Grafana: http://localhost:3000 (admin/admin) — ColdBrew dashboard pre-loaded - Jaeger: http://localhost:16686 — distributed traces - Prometheus: http://localhost:9100 -- Alloy: http://localhost:12345 — OTEL collector UI ## Load Testing diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 6eb0ba4..dad8fed 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-down local-stack-logs local-stack-reset local-psql loadtest +.PHONY: build build-alpine clean test default deps generate run run-docker fmt help bench build-docker coverage-html dep lint mock runj vulncheck local-stack local-stack-obs local-stack-down local-stack-logs local-stack-reset local-exec loadtest BIN_NAME={{cookiecutter.app_name}} GOPRIVATE ?= {{cookiecutter.goprivate}} @@ -36,11 +36,12 @@ help: @echo ' make runj Run the project locally with jq log parsing.' @echo ' make test Run tests.' @echo ' make loadtest Run gRPC load test (ghz) against the running service.' - @echo ' make local-stack Start local dev stack (override with PROFILES="postgres kafka obs").' + @echo ' make local-stack Start local dev stack (override with PROFILES="postgres kafka").' + @echo ' make local-stack-obs Start local dev stack + observability (Prometheus, Grafana, Jaeger).' @echo ' make local-stack-down Stop local dev stack.' @echo ' make local-stack-logs Follow local dev stack logs.' @echo ' make local-stack-reset Reset local dev stack.' - @echo ' make local-psql Open psql shell in local Postgres.' + @echo ' make local-exec SVC=postgres CMD="psql -U postgres" Exec into a service container.' @echo build: @@ -119,7 +120,7 @@ run-docker: build-docker DOCKER_COMPOSE ?= docker-compose COMPOSE_FILE := docker-compose.local.yml -PROFILES ?= {{ cookiecutter['local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,adminer)'] | replace(',', ' ') }} +PROFILES ?= {{ cookiecutter['local_services (postgres,mysql,cockroachdb,mongodb,redis,valkey,memcached,kafka,nats,elasticsearch,ministack,dynamodb,spanner,pubsub,bigtable,firestore,alloydb,adminer)'] | replace(',', ' ') }} PROFILE_FLAGS := $(foreach p,$(PROFILES),--profile $(p)) local-stack: @@ -132,30 +133,43 @@ local-stack: @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port cockroachdb 26257 2>/dev/null | sed 's|.*:\(.*\)| CockroachDB: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port mongodb 27017 2>/dev/null | sed 's|.*:\(.*\)| MongoDB: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port redis 6379 2>/dev/null | sed 's|.*:\(.*\)| Redis: localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port valkey 6379 2>/dev/null | sed 's|.*:\(.*\)| Valkey: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port valkey 6379 2>/dev/null | sed 's|.*:\(.*\)| Valkey: localhost:\1 (Redis-compatible)|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port memcached 11211 2>/dev/null | sed 's|.*:\(.*\)| Memcached: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port kafka 9092 2>/dev/null | sed 's|.*:\(.*\)| Kafka: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port nats 4222 2>/dev/null | sed 's|.*:\(.*\)| NATS: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port elasticsearch 9200 2>/dev/null | sed 's|.*:\(.*\)| Elasticsearch: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port ministack 4566 2>/dev/null | sed 's|.*:\(.*\)| MiniStack: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port dynamodb 8000 2>/dev/null | sed 's|.*:\(.*\)| DynamoDB: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port spanner 9010 2>/dev/null | sed 's|.*:\(.*\)| Spanner: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port pubsub 8085 2>/dev/null | sed 's|.*:\(.*\)| Pub/Sub: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port bigtable 8086 2>/dev/null | sed 's|.*:\(.*\)| Bigtable: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port firestore 8080 2>/dev/null | sed 's|.*:\(.*\)| Firestore: localhost:\1|' || true + @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port alloydb 5432 2>/dev/null | sed 's|.*:\(.*\)| AlloyDB: localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port adminer 8080 2>/dev/null | sed 's|.*:\(.*\)| Adminer: http://localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port prometheus 9090 2>/dev/null | sed 's|.*:\(.*\)| Prometheus: http://localhost:\1|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port grafana 3000 2>/dev/null | sed 's|.*:\(.*\)| Grafana: http://localhost:\1 (admin/admin)|' || true @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port jaeger 16686 2>/dev/null | sed 's|.*:\(.*\)| Jaeger: http://localhost:\1|' || true - @$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) port alloy 12345 2>/dev/null | sed 's|.*:\(.*\)| Alloy: http://localhost:\1|' || true @echo +local-stack-obs: + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) --profile obs up -d --wait --remove-orphans + local-stack-down: - $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) down + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) down local-stack-logs: $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) logs -f local-stack-reset: local-stack-down local-stack -local-psql: - $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile postgres exec postgres sh -c 'PGPASSWORD=postgres psql -U postgres -d {{cookiecutter.app_name}}_dev' +local-exec: +ifndef SVC + $(error SVC is required. Usage: make local-exec SVC=postgres CMD="psql -U postgres") +endif +ifndef CMD + $(error CMD is required. Usage: make local-exec SVC=postgres CMD="psql -U postgres") +endif + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile $(SVC) exec $(SVC) $(CMD) loadtest: ghz --config misc/loadtest/echo.json diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 82a705c..aeb0814 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -44,7 +44,7 @@ Available profiles: `postgres`, `mysql`, `cockroachdb`, `mongodb`, `redis`, `val ```console $ make local-stack-down # Stop infrastructure $ make local-stack-reset # Reset infrastructure -$ make local-psql # Open Postgres shell +$ make local-exec SVC=postgres CMD="psql -U postgres" # Exec into any service ``` ## Docker diff --git a/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy b/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy deleted file mode 100644 index 75e144b..0000000 --- a/{{cookiecutter.app_name}}/deploy/local/alloy/config.alloy +++ /dev/null @@ -1,21 +0,0 @@ -// Grafana Alloy configuration for local development -// Receives OTLP telemetry and forwards to Prometheus + Jaeger - -otelcol.receiver.otlp "default" { - grpc { - endpoint = "0.0.0.0:4318" - } - - output { - traces = [otelcol.exporter.otlp.jaeger.input] - } -} - -otelcol.exporter.otlp "jaeger" { - client { - endpoint = "jaeger:4317" - tls { - insecure = true - } - } -} diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index 01f6a34..be0212d 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -49,7 +49,7 @@ services: restart: always profiles: ["valkey"] ports: - - "6379:6379" + - "6380:6379" memcached: image: memcached:alpine @@ -144,6 +144,16 @@ services: ports: - "8080:8080" + alloydb: + image: google/alloydbomni:latest + restart: always + profiles: ["alloydb"] + ports: + - "5434:5432" + environment: + POSTGRES_PASSWORD: "postgres" + POSTGRES_DB: "{{cookiecutter.app_name}}_dev" + # --- Tools --- adminer: image: adminer @@ -183,14 +193,3 @@ services: - "4317:4317" environment: COLLECTOR_OTLP_ENABLED: "true" - - alloy: - image: grafana/alloy:latest - restart: always - profiles: ["obs"] - ports: - - "12345:12345" - - "4318:4318" - volumes: - - ./deploy/local/alloy/config.alloy:/etc/alloy/config.alloy - command: run /etc/alloy/config.alloy diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics.go b/{{cookiecutter.app_name}}/service/metrics/metrics.go index a3550c2..8799f36 100644 --- a/{{cookiecutter.app_name}}/service/metrics/metrics.go +++ b/{{cookiecutter.app_name}}/service/metrics/metrics.go @@ -22,12 +22,6 @@ var ( Help: "Duration of Echo RPC calls in seconds.", Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, }, []string{"outcome"}) - - activeRequests = promauto.NewGauge(prometheus.GaugeOpts{ - Namespace: namespace, - Name: "active_requests", - Help: "Number of currently active requests.", - }) ) type appMetrics struct{} @@ -44,7 +38,3 @@ func (m *appMetrics) IncEchoTotal(outcome string) { func (m *appMetrics) ObserveEchoDuration(outcome string, duration time.Duration) { echoDuration.WithLabelValues(outcome).Observe(duration.Seconds()) } - -func (m *appMetrics) SetActiveRequests(count int) { - activeRequests.Set(float64(count)) -} diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics_test.go b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go index 151e6f2..851cfee 100644 --- a/{{cookiecutter.app_name}}/service/metrics/metrics_test.go +++ b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go @@ -45,17 +45,3 @@ func TestObserveEchoDuration(t *testing.T) { t.Fatal("expected at least one observation") } } - -func TestSetActiveRequests(t *testing.T) { - m := New() - m.SetActiveRequests(5) - - mf := gatherMetric(namespace + "_active_requests") - if mf == nil { - t.Fatal("metric not found") - } - val := mf.GetMetric()[0].GetGauge().GetValue() - if val != 5 { - t.Fatalf("expected 5, got %f", val) - } -} diff --git a/{{cookiecutter.app_name}}/service/metrics/types.go b/{{cookiecutter.app_name}}/service/metrics/types.go index 05ceabc..8acfe9c 100644 --- a/{{cookiecutter.app_name}}/service/metrics/types.go +++ b/{{cookiecutter.app_name}}/service/metrics/types.go @@ -11,7 +11,4 @@ type Metrics interface { // Echo RPC metrics IncEchoTotal(outcome string) ObserveEchoDuration(outcome string, duration time.Duration) - - // Active requests gauge - SetActiveRequests(count int) } diff --git a/{{cookiecutter.app_name}}/service/service_test.go b/{{cookiecutter.app_name}}/service/service_test.go index 3a008de..eaade79 100644 --- a/{{cookiecutter.app_name}}/service/service_test.go +++ b/{{cookiecutter.app_name}}/service/service_test.go @@ -5,7 +5,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/config" + "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/service/metrics" + mockmetrics "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/misc/mocks/metrics" proto "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/proto" ) @@ -47,12 +50,15 @@ func TestEcho(t *testing.T) { const prefix = "testPrefix" const msg = "hello" - s, err := New(config.Get()) - assert.NoError(t, err) - assert.NotNil(t, s) + m := mockmetrics.NewMetrics(t) + m.EXPECT().IncEchoTotal(metrics.OutcomeSuccess).Once() + m.EXPECT().ObserveEchoDuration(metrics.OutcomeSuccess, mock.AnythingOfType("time.Duration")).Once() - // override the prefix - s.prefix = prefix + s := &svc{ + Server: GetHealthCheckServer(), + monitoring: m, + prefix: prefix, + } resp, err := s.Echo(context.Background(), &proto.EchoRequest{Msg: msg}) assert.NoError(t, err) From ed0c4d1526b9db340a90c35e46c38853a66bb584 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 22:24:19 +0800 Subject: [PATCH 12/17] fix: stale deps reference in AGENTS.md load testing note --- {{cookiecutter.app_name}}/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index 80fc31a..7f60d2d 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -149,7 +149,7 @@ make loadtest # run load test in another terminal The default config (`misc/loadtest/echo.json`) sends 1000 requests at concurrency 10 to the Echo RPC via gRPC reflection. Edit the file to adjust total, concurrency, or target a different RPC. -With the obs profile running (`make local-stack PROFILES="deps obs"`), load test results are visible in the Grafana dashboard in real-time. +With the observability stack running (`make local-stack-obs`), load test results are visible in the Grafana dashboard in real-time. ## Rules From 8722574bdff63e64a0ef91cfdb1a11d65233f79a Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 22:32:43 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20PR=20review=20=E2=80=94=20Grafana?= =?UTF-8?q?=20UID,=20health=20checks,=20Linux=20support,=20skip=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Grafana datasource UID mismatch — add uid: PBFA97CFB590B2093 to provisioned datasource so dashboard panels find it - Add health checks for postgres, mysql, redis, elasticsearch (--wait now actually waits for readiness) - Add extra_hosts: host.docker.internal:host-gateway to Prometheus (fixes Linux where host.docker.internal doesn't resolve by default) - Print warning when COOKIECUTTER_SKIP_PROTO_INIT skips proto init - Fix AGENTS.md: "sends requests for 10 seconds" not "1000 requests" - Add alloydb to README.md available profiles list - 58 tests pass --- hooks/post_gen_project.py | 5 ++++- {{cookiecutter.app_name}}/AGENTS.md | 2 +- {{cookiecutter.app_name}}/README.md | 2 +- .../provisioning/datasources/prometheus.yml | 1 + .../docker-compose.local.yml | 22 +++++++++++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 8d6722d..7b8f646 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -95,7 +95,10 @@ def remove_docker_compose(): if os.path.exists(deploy_dir): shutil.rmtree(deploy_dir) -if os.environ.get("COOKIECUTTER_SKIP_PROTO_INIT") != "1": +if os.environ.get("COOKIECUTTER_SKIP_PROTO_INIT") == "1": + print("WARNING: COOKIECUTTER_SKIP_PROTO_INIT=1 — skipping proto initialization.") + print(" Run 'make generate && make mock && go mod tidy' manually before building.") +else: init_proto() setup_local_env() diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index 7f60d2d..c99ae66 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -147,7 +147,7 @@ make run # start the app in one terminal make loadtest # run load test in another terminal ``` -The default config (`misc/loadtest/echo.json`) sends 1000 requests at concurrency 10 to the Echo RPC via gRPC reflection. Edit the file to adjust total, concurrency, or target a different RPC. +The default config (`misc/loadtest/echo.json`) sends requests for 10 seconds at concurrency 10 to the Echo RPC via gRPC reflection. Edit the file to adjust duration, concurrency, or target a different RPC. With the observability stack running (`make local-stack-obs`), load test results are visible in the Grafana dashboard in real-time. diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index aeb0814..1631929 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -39,7 +39,7 @@ $ make local-stack PROFILES="postgres kafka obs" # Override with specific servi $ make run # Run the app (fast native build) ``` -Available profiles: `postgres`, `mysql`, `cockroachdb`, `mongodb`, `redis`, `valkey`, `memcached`, `kafka`, `nats`, `elasticsearch`, `ministack`, `dynamodb`, `spanner`, `pubsub`, `bigtable`, `firestore`, `adminer`, `obs` +Available profiles: `postgres`, `mysql`, `cockroachdb`, `mongodb`, `redis`, `valkey`, `memcached`, `kafka`, `nats`, `elasticsearch`, `ministack`, `dynamodb`, `spanner`, `pubsub`, `bigtable`, `firestore`, `alloydb`, `adminer`, `obs` ```console $ make local-stack-down # Stop infrastructure diff --git a/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml index 4e6c2e9..845bb66 100644 --- a/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml +++ b/{{cookiecutter.app_name}}/deploy/local/grafana/provisioning/datasources/prometheus.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Prometheus type: prometheus + uid: PBFA97CFB590B2093 url: http://prometheus:9090 isDefault: true editable: true diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index be0212d..c87eb48 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -9,6 +9,11 @@ services: environment: POSTGRES_DB: "{{cookiecutter.app_name}}_dev" POSTGRES_PASSWORD: "postgres" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 10 mysql: image: mysql:8 @@ -19,6 +24,11 @@ services: environment: MYSQL_ROOT_PASSWORD: "root" MYSQL_DATABASE: "{{cookiecutter.app_name}}_dev" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 2s + timeout: 5s + retries: 10 cockroachdb: image: cockroachdb/cockroach:latest @@ -43,6 +53,11 @@ services: profiles: ["redis"] ports: - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 10 valkey: image: valkey/valkey:8-alpine @@ -95,6 +110,11 @@ services: discovery.type: "single-node" xpack.security.enabled: "false" ES_JAVA_OPTS: "-Xms512m -Xmx512m" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 5s + timeout: 10s + retries: 20 # --- AWS --- ministack: @@ -169,6 +189,8 @@ services: profiles: ["obs"] ports: - "9100:9090" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./deploy/local/prometheus.yml:/etc/prometheus/prometheus.yml From 31c1d911f1adaf83574ec3d0a1598a2c512a6d7e Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 23:02:41 +0800 Subject: [PATCH 14/17] fix: ministack image name, gatherMetric error handling, alloydb in Makefile comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ministack Docker image: ministack/ministack → nahuelnucera/ministack - gatherMetric now captures and reports Gather() errors instead of discarding - Add alloydb to Makefile available profiles comment --- {{cookiecutter.app_name}}/Makefile | 2 +- {{cookiecutter.app_name}}/docker-compose.local.yml | 2 +- .../service/metrics/metrics_test.go | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index dad8fed..1a09f53 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -111,7 +111,7 @@ run-docker: build-docker # Local development stack (docker-compose) # Available profiles: postgres, mysql, cockroachdb, mongodb, redis, valkey, # memcached, kafka, nats, elasticsearch, ministack, dynamodb, spanner, -# pubsub, bigtable, firestore, adminer, obs +# pubsub, bigtable, firestore, alloydb, adminer, obs # Usage: # make local-stack # start selected services # make local-stack PROFILES="postgres kafka obs" # override profiles diff --git a/{{cookiecutter.app_name}}/docker-compose.local.yml b/{{cookiecutter.app_name}}/docker-compose.local.yml index c87eb48..0900722 100644 --- a/{{cookiecutter.app_name}}/docker-compose.local.yml +++ b/{{cookiecutter.app_name}}/docker-compose.local.yml @@ -118,7 +118,7 @@ services: # --- AWS --- ministack: - image: ministack/ministack:latest + image: nahuelnucera/ministack:latest restart: always profiles: ["ministack"] ports: diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics_test.go b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go index 851cfee..0afd0e5 100644 --- a/{{cookiecutter.app_name}}/service/metrics/metrics_test.go +++ b/{{cookiecutter.app_name}}/service/metrics/metrics_test.go @@ -8,8 +8,12 @@ import ( dto "github.com/prometheus/client_model/go" ) -func gatherMetric(name string) *dto.MetricFamily { - families, _ := prometheus.DefaultGatherer.Gather() +func gatherMetric(t *testing.T, name string) *dto.MetricFamily { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("failed to gather metrics: %v", err) + } for _, f := range families { if f.GetName() == name { return f @@ -23,7 +27,7 @@ func TestIncEchoTotal(t *testing.T) { m.IncEchoTotal(OutcomeSuccess) m.IncEchoTotal(OutcomeError) - mf := gatherMetric(namespace + "_echo_total") + mf := gatherMetric(t, namespace+"_echo_total") if mf == nil { t.Fatal("metric not found") } @@ -36,7 +40,7 @@ func TestObserveEchoDuration(t *testing.T) { m := New() m.ObserveEchoDuration(OutcomeSuccess, 50*time.Millisecond) - mf := gatherMetric(namespace + "_echo_duration_seconds") + mf := gatherMetric(t, namespace+"_echo_duration_seconds") if mf == nil { t.Fatal("metric not found") } From 58d690a11aa3c3acd26fefbb01ffdc7c34a129d4 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 23:26:49 +0800 Subject: [PATCH 15/17] fix: local-stack-obs reuses endpoint display, local-stack-down uses --profile "*" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local-stack-obs delegates to local-stack via recursive make with obs appended to PROFILES — gets full endpoint display including Grafana/Jaeger - local-stack-down uses --profile "*" to stop all profiled services (plain down without profiles didn't see profiled services) --- {{cookiecutter.app_name}}/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/{{cookiecutter.app_name}}/Makefile b/{{cookiecutter.app_name}}/Makefile index 1a09f53..e03fda2 100644 --- a/{{cookiecutter.app_name}}/Makefile +++ b/{{cookiecutter.app_name}}/Makefile @@ -152,10 +152,10 @@ local-stack: @echo local-stack-obs: - $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) --profile obs up -d --wait --remove-orphans + $(MAKE) local-stack PROFILES="$(PROFILES) obs" local-stack-down: - $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) down + $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) --profile "*" down local-stack-logs: $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) $(PROFILE_FLAGS) logs -f From f05db4e08f912319a29c67d87b5c2bfa946a98e0 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Wed, 8 Apr 2026 23:38:47 +0800 Subject: [PATCH 16/17] chore: reorder cookiecutter prompts, update README features list - Move goprivate after project_short_description (project identity first) - Add all 11 prompts to README example output - Add to features: local dev stack, Grafana/Jaeger, metrics, load testing, protovalidate --- README.md | 12 ++++++++++-- cookiecutter.json | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 521dbf3..62e9b02 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,17 @@ Powered by [Cookiecutter](https://github.com/cookiecutter/cookiecutter), Cookiec - Complete gRPC service with HTTP/JSON gateway (grpc-gateway) - Kubernetes health checks (liveness + readiness probes) - Prometheus metrics, distributed tracing, structured logging +- Request validation via [protovalidate](https://github.com/bufbuild/protovalidate) annotations - Swagger UI for interactive API documentation - Multi-stage Docker build for minimal production images - CI/CD pipelines for GitHub Actions and GitLab CI - golangci-lint v2 configuration with govulncheck - Makefile with build, test, lint, benchmark, and run targets - Build-time version injection (git commit, branch, date) +- Local dev stack with 20 docker-compose profiles (databases, caches, brokers, AWS/GCP emulators) +- Grafana dashboard + Jaeger tracing pre-configured in obs profile +- Application metrics package (interface-based, mockable, promauto) +- gRPC load testing with [ghz](https://ghz.sh) ## Prerequisites @@ -38,16 +43,19 @@ Answer the prompts: ```shell source_path [github.com/ankurs]: github.com/yourname -app_name [MyApp]: EchoServer +name [MyApp]: EchoServer grpc_package [com.github.ankurs]: com.github.yourname service_name [MySvc]: EchoSvc -project_short_description [A Golang project.]: My first ColdBrew service +project_short_description [EchoServer is a Golang project.]: +goprivate []: docker_image [alpine:latest]: docker_build_image [golang]: Select docker_build_image_version: 1 - 1.26 2 - 1.25 Choose from 1, 2 [1]: 1 +include_docker_compose [y/n] (y): +local_services (postgres,mysql,...,adminer) [postgres,redis]: ``` Then build and run: diff --git a/cookiecutter.json b/cookiecutter.json index 092e92c..05be62e 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,8 +4,8 @@ "app_name": "{{cookiecutter.name | replace(' ', '_') | lower}}", "grpc_package": "com.github.ankurs", "service_name": "MySvc", - "goprivate": "", "project_short_description": "{{cookiecutter.name}} is a Golang project.", + "goprivate": "", "docker_image": "alpine:latest", "docker_build_image": "golang", "docker_build_image_version": ["1.26", "1.25"], From d1c867ca515db6dc4195e060ed083f257259865b Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Thu, 9 Apr 2026 10:08:22 +0800 Subject: [PATCH 17/17] fix: sanitize hyphens/dots in Prometheus metrics namespace --- {{cookiecutter.app_name}}/service/metrics/metrics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.app_name}}/service/metrics/metrics.go b/{{cookiecutter.app_name}}/service/metrics/metrics.go index 8799f36..ca89ace 100644 --- a/{{cookiecutter.app_name}}/service/metrics/metrics.go +++ b/{{cookiecutter.app_name}}/service/metrics/metrics.go @@ -7,7 +7,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) -const namespace = "{{cookiecutter.app_name}}" +const namespace = "{{ cookiecutter.app_name | replace('-', '_') | replace('.', '_') }}" var ( echoTotal = promauto.NewCounterVec(prometheus.CounterOpts{