From b65fda85b850ffacd535e968fe98626a2d364b35 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 01:53:36 -0400 Subject: [PATCH 01/10] docs: plan google analytics provisioning --- ...26-google-analytics-provisioning-design.md | 189 ++++++++++++++++++ ...026-05-26-google-analytics-provisioning.md | 121 +++++++++++ 2 files changed, 310 insertions(+) create mode 100644 docs/plans/2026-05-26-google-analytics-provisioning-design.md create mode 100644 docs/plans/2026-05-26-google-analytics-provisioning.md diff --git a/docs/plans/2026-05-26-google-analytics-provisioning-design.md b/docs/plans/2026-05-26-google-analytics-provisioning-design.md new file mode 100644 index 0000000..d1e1026 --- /dev/null +++ b/docs/plans/2026-05-26-google-analytics-provisioning-design.md @@ -0,0 +1,189 @@ +# Google Analytics Provisioning Design + +## Goal + +Extend the existing public `workflow-plugin-analytics` plugin from HTML snippet injection into idempotent provisioning for Google Analytics 4 web streams and Google Tag Manager web containers. + +The deploy target is Workflow/wfctl: site repos and apps declare the desired analytics resources, `wfctl analytics google ...` or Workflow steps reconcile them, and existing injection uses the returned GA measurement ID or GTM public container ID. Live apply is blocked until an operator provides Google API credentials and access to the target Analytics and Tag Manager accounts. + +## Current State + +- `workflow-plugin-analytics` is public (`plugin.json private: false`) and already supports GA4 `gtag.js` and GTM HTML injection. +- It has no Google SDK clients, no provider module, no provisioning CLI, no state/audit file, and no way to create/find GA properties, GA web data streams, GTM containers, workspaces, or Google tag configs. +- `gocodealone-multisite` already models `multisite.yaml.analytics.google.measurement_id`; that field currently assumes the ID was manually created elsewhere. + +## Global Design Guidance + +Source: `/Users/jon/workspace/docs/design-guidance.md` + +| guidance | design response | +|---|---| +| Workflow platform is the substrate; no standalone tools | Extend `workflow-plugin-analytics` with plugin modules, steps, and `wfctl` CLI passthrough. | +| Reuse over rebuild | Extend the existing analytics plugin instead of creating separate GA/GTM repos. | +| Primary language Go; official SDKs isolated | Use official Google Go SDK packages behind small internal interfaces. | +| Secrets never logged | Credentials are loaded from env/file/module config and audit output records only resource IDs. | +| Audit trail for state-mutating ops | Append JSONL audit events under `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/analytics/google-audit.jsonl`. | +| Cost discipline; live cloud tests opt-in | Unit tests use fake SDK interfaces. Live reconciliation is gated by explicit credentials and command invocation. | +| End-to-end via real consumer | Add gocodealone-multisite deploy/runbook steps showing the plugin pin and dry-run/apply sequence, stopping before live apply. | + +## References + +- Google Analytics Admin Go client exposes `AnalyticsAdminClient.CreateProperty`, `ListProperties`, `CreateDataStream`, and `ListDataStreams` in `cloud.google.com/go/analytics/admin/apiv1alpha`. +- Google Tag Manager Go client exposes `tagmanager.NewService`, container, workspace, and `GtagConfig` services in `google.golang.org/api/tagmanager/v2`. +- GA web streams expose output `measurement_id`; GTM containers expose output `publicId`. + +## Approaches + +1. **Manual IDs plus existing injection only.** + - Pros: no new code. + - Cons: does not satisfy programmatic provisioning or tracking; repeats manual console work per site. +2. **New separate provider plugins (`workflow-plugin-google-analytics`, `workflow-plugin-google-tag-manager`).** + - Pros: narrow repositories. + - Cons: fragments an already public analytics plugin and makes shared inject/provision flows harder. +3. **Extend `workflow-plugin-analytics` with Google provisioning.** + - Pros: one public analytics surface, preserves current injection, lets provisioning and injection share provider naming and validation. + - Cons: plugin grows beyond HTML mutation into cloud API reconciliation. + +Chosen: option 3. The responsibility is still "analytics/tag-manager", and the existing plugin is already the public integration point. + +## Design + +### Provider Module + +Add module type `analytics.google_provider`. + +Config: + +```yaml +modules: + - name: google-analytics + type: analytics.google_provider + config: + credentials_json_env: GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON + credentials_file_env: GOOGLE_APPLICATION_CREDENTIALS + analytics_account: accounts/123456789 + tag_manager_account: accounts/987654321 + audit_path: "" +``` + +The module registers a provider by name. Empty credentials use Google Application Default Credentials. Inline JSON and file paths are never logged. `analytics_account` and `tag_manager_account` can be overridden per step/CLI call to support umbrella accounts. + +### GA4 Reconcile + +Add `EnsureGA4WebStream(ctx, request)`: + +- Validate account as `accounts/`. +- List properties under `parent:` and find exact `display_name`. +- Create the property only if missing. +- List web data streams under the property and find exact `display_name` or matching default URI. +- Create the web data stream only if missing. +- Return resource names and measurement ID. + +`dry_run` returns the intended operations without creating resources. + +### GTM Reconcile + +Add `EnsureGTMWebContainer(ctx, request)`: + +- Validate GTM account as `accounts/`. +- List containers and find exact `name` or matching domain set. +- Create a web container only if missing. +- Ensure a workspace exists by name. +- If `measurement_id` is present, ensure a Google tag config exists in that workspace for GA4. +- Return container ID, public ID, workspace path, and operation list. + +Publishing versions is out of scope for the first pass. The operator can inspect/publish in GTM, or a later task can add version submit/publish once the first live reconcile proves the account model. + +### CLI + +Extend the existing `analytics` command: + +```sh +wfctl analytics google ga4 ensure \ + --account accounts/123456789 \ + --property-name gocodealone.tech \ + --stream-name gocodealone.tech \ + --default-uri https://gocodealone.tech \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run + +wfctl analytics google gtm ensure \ + --account accounts/987654321 \ + --container-name gocodealone.tech \ + --domain gocodealone.tech \ + --measurement-id G-XXXXXXXXXX \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run +``` + +The CLI prints JSON output so deploy jobs can capture returned IDs. + +### Workflow Steps + +Add: + +- `step.analytics_google_ga4_ensure` +- `step.analytics_google_gtm_ensure` + +Both steps accept module name, account override, names/domains, `dry_run`, and credentials overrides. They return structured IDs and operation summaries for downstream injection. + +### gocodealone-multisite Deployment Shape + +Add runbook/config guidance, not live apply: + +1. Add `workflow-plugin-analytics` to `wfctl.yaml`. +2. Add Google API secret entries in `deploy.prereq.yaml`/`deploy.yaml`. +3. Add a pre-deploy dry-run command that ensures a GA4 property/web stream for each known site domain. +4. Add optional GTM dry-run if the site wants container-managed tags. +5. After operator provisions API access, run the dry-run and inspect JSON output. +6. Only then run live apply and update each content repo `multisite.yaml.analytics.google.measurement_id`. + +The first implementation documents these commands and stops before running live Google API calls. + +## Security Review + +- Credentials are read from env/file/ADC and passed to Google SDK options only. +- Errors must not echo credential values. +- Audit JSONL records timestamp, action, dry-run flag, account/resource names, and operation names, not secrets. +- Least privilege requires Google Analytics Admin access to target GA accounts and GTM account access for container management. +- The plugin does not expose public HTTP endpoints; abuse risk is deploy/operator-side credential misuse. + +## Infrastructure Impact + +Creates Google Analytics properties/web data streams and Google Tag Manager containers/workspaces/configs when live apply is used. These are third-party SaaS resources, not DigitalOcean infrastructure. Live mutation requires explicit operator credentials and should run from controlled deploy environments. + +## Multi-Component Validation + +- Unit tests use fake GA/GTM clients to prove idempotent list-before-create behavior. +- CLI tests run dry-run commands and assert JSON output. +- Step tests load the provider module and execute ensure steps against fake clients. +- Consumer validation is a gocodealone-multisite deploy/runbook dry-run. Live apply remains blocked until API access exists. + +## Assumptions + +- The Google service account or ADC principal can be granted access to the Analytics and GTM accounts. +- Exact display names are acceptable idempotency keys for first pass. +- A GA4 web stream measurement ID is sufficient for existing injection. +- GTM publish/versioning can wait until after initial container/workspace/config provisioning is proven. + +## Self-Challenge + +1. Laziest solution: keep a spreadsheet of IDs and inject env vars. Rejected because the user asked to provision and track IDs programmatically across many sites. +2. Fragile assumption: display-name matching may collide. Mitigation: require account scoping, return existing IDs, and document that names should be domain-like and unique. +3. YAGNI risk: GTM config creation might be too much for pass one. Kept because the ask explicitly includes GTM and programmatic management; publish/versioning is deferred. + +## Rollback + +- Revert the plugin pin/config in consumer apps. +- Re-run injection with empty tag/container IDs to remove managed snippets. +- Do not auto-delete Google resources in rollback; deletion risks historical data loss. Operators can archive/delete manually after audit review. +- Revert this plugin PR if provisioning code breaks existing injection; existing tests cover injection compatibility. + +## Deployment Blocker + +Stop before live Google API mutation. Required operator inputs: + +- Google Cloud project/API enablement for Analytics Admin API and Tag Manager API. +- Credential secret value or ADC setup. +- GA account IDs and GTM account IDs for each umbrella. +- Confirmation that the principal has create/list permissions in those accounts. diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md b/docs/plans/2026-05-26-google-analytics-provisioning.md new file mode 100644 index 0000000..93fd058 --- /dev/null +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md @@ -0,0 +1,121 @@ +# Google Analytics Provisioning Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. + +**Goal:** Add Google Analytics 4 and Google Tag Manager provisioning surfaces to the existing public Workflow analytics plugin. + +**Architecture:** Keep `workflow-plugin-analytics` as the public plugin. Add a Google provider module, SDK-backed reconcile interfaces, dry-run/idempotent CLI and steps, audit JSONL, and gocodealone-multisite deployment guidance that stops before live apply. + +**Tech Stack:** Go 1.26, `cloud.google.com/go/analytics/admin/apiv1alpha`, `google.golang.org/api/tagmanager/v2`, Workflow external plugin SDK. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 6 +**Estimated Lines of Change:** ~1100 + +**Out of scope:** +- GTM container version publishing. +- Automatic deletion/archive of Analytics or Tag Manager resources. +- Live Google API apply before operator credentials and account access exist. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | Add Google analytics provisioning | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/google-analytics-provisioning | + +**Status:** Draft + +## Task 1: Google Provider Module and Credentials + +**Files:** +- Modify: `internal/plugin.go` +- Create: `internal/google_provider.go` +- Create: `internal/google_provider_test.go` +- Modify: `plugin.json` + +**Steps:** +1. Write failing tests that `analytics.google_provider` is exposed, registers config by module name, supports env/file/ADC credential inputs, and redacts credential values in validation errors. +2. Run `GOWORK=off go test ./internal -run 'TestGoogleProvider|TestPluginExposes' -count=1`; expected: FAIL because module type does not exist. +3. Implement `GoogleProviderConfig`, provider registry, module lifecycle, and plugin `ModuleTypes/CreateModule`. +4. Run the focused tests; expected: PASS. +5. Rollback: revert this task commit to remove the module surface and plugin manifest entry. + +## Task 2: GA4 Web Stream Reconciler + +**Files:** +- Create: `internal/google_ga4.go` +- Create: `internal/google_ga4_test.go` + +**Steps:** +1. Write failing tests for dry-run create plan, existing property reuse, existing stream reuse, create-property-then-create-stream, and invalid account/default URI validation. +2. Run `GOWORK=off go test ./internal -run TestEnsureGA4 -count=1`; expected: FAIL because reconciler does not exist. +3. Implement a fakeable `GA4AdminClient` interface plus SDK adapter using `cloud.google.com/go/analytics/admin/apiv1alpha`. +4. Implement `EnsureGA4WebStream` with list-before-create and operation summary output. +5. Run the focused tests; expected: PASS. +6. Rollback: revert this task commit; no live resources are touched by tests. + +## Task 3: GTM Web Container Reconciler + +**Files:** +- Create: `internal/google_gtm.go` +- Create: `internal/google_gtm_test.go` + +**Steps:** +1. Write failing tests for dry-run create plan, existing container reuse, existing workspace reuse, optional GA4 config ensure, and invalid account/domain validation. +2. Run `GOWORK=off go test ./internal -run TestEnsureGTM -count=1`; expected: FAIL because reconciler does not exist. +3. Implement a fakeable `TagManagerClient` interface plus SDK adapter using `google.golang.org/api/tagmanager/v2`. +4. Implement `EnsureGTMWebContainer` with list-before-create and operation summary output. +5. Run the focused tests; expected: PASS. +6. Rollback: revert this task commit; no live resources are touched by tests. + +## Task 4: CLI JSON Surfaces + +**Files:** +- Modify: `internal/cli.go` +- Modify: `internal/cli_test.go` +- Modify: `README.md` +- Modify: `plugin.json` + +**Steps:** +1. Write failing CLI tests for `analytics google ga4 ensure --dry-run` and `analytics google gtm ensure --dry-run` JSON output. +2. Run `GOWORK=off go test ./internal -run TestCLIAnalyticsGoogle -count=1`; expected: FAIL because subcommands do not exist. +3. Implement nested CLI parsing, JSON output, credential flags, and dry-run default examples. +4. Run the focused tests; expected: PASS. +5. Rollback: revert this task commit; existing `analytics inject` behavior remains in prior commits. + +## Task 5: Workflow Ensure Steps + +**Files:** +- Create: `internal/google_steps.go` +- Create: `internal/google_steps_test.go` +- Modify: `internal/plugin.go` +- Modify: `plugin.json` +- Modify: `plugin.contracts.json` + +**Steps:** +1. Write failing tests that `step.analytics_google_ga4_ensure` and `step.analytics_google_gtm_ensure` execute dry-run through fake clients and output IDs/operations. +2. Run `GOWORK=off go test ./internal -run TestAnalyticsGoogle.*Step -count=1`; expected: FAIL because step types do not exist. +3. Implement step factories using the provider registry and per-step overrides. +4. Run the focused tests; expected: PASS. +5. Rollback: revert this task commit to remove provisioning step types. + +## Task 6: Consumer Deployment Guidance and Verification + +**Files:** +- Modify: `README.md` +- Create: `docs/gocodealone-multisite-google-analytics.md` +- Modify: `examples/minimal/config.yaml` + +**Steps:** +1. Add docs for gocodealone-multisite plugin pin, Google secrets, GA/GTM dry-run commands, live apply blocker, and measurement ID injection flow. +2. Add an example Workflow config showing provider module, GA4 ensure step, GTM ensure step, and existing HTML injection step consuming returned IDs. +3. Run `GOWORK=off go test ./...`; expected: PASS. +4. Run `GOWORK=off go build ./...`; expected: PASS. +5. Run CLI smoke: `GOWORK=off go run ./cmd/workflow-plugin-analytics analytics google ga4 ensure --account accounts/123 --property-name example.com --stream-name example.com --default-uri https://example.com --dry-run`; expected JSON includes `"dry_run":true` and `"measurement_id":""`. +6. Rollback: revert docs/example commit; no live resources touched. From 7babcb6e3efeac1c454e679b240c4ed0b881a972 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 01:54:10 -0400 Subject: [PATCH 02/10] docs: review google analytics provisioning plan --- ...alytics-provisioning-adversarial-review.md | 39 +++++++++++++++++++ ...026-05-26-google-analytics-provisioning.md | 9 +++-- 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-05-26-google-analytics-provisioning-adversarial-review.md diff --git a/docs/plans/2026-05-26-google-analytics-provisioning-adversarial-review.md b/docs/plans/2026-05-26-google-analytics-provisioning-adversarial-review.md new file mode 100644 index 0000000..b9eabc1 --- /dev/null +++ b/docs/plans/2026-05-26-google-analytics-provisioning-adversarial-review.md @@ -0,0 +1,39 @@ +# Google Analytics Provisioning Adversarial Review + +## Design Phase + +**Status:** PASS after author revision. + +**Findings** + +- Important: audit JSONL was a global guidance requirement in the design but absent from the implementation plan. Fixed by adding `internal/google_audit.go` and tests to Task 1. +- Important: the live-apply blocker could be bypassed accidentally because the verification section only tested dry-run. Fixed by adding an explicit no-credentials live command check to Task 6. +- Minor: GTM publish/versioning is deferred. Acceptable because the design returns container/workspace/config IDs and documents publish as out of scope. + +**Bug-class scan transcript** + +| Class | Result | Note | +|---|---|---| +| Project-guidance conflicts | Fixed | Audit trail requirement now maps to a task. | +| Assumptions under attack | Clean | Credential/account access assumptions are explicit and become the deploy blocker. | +| Repo-precedent conflicts | Clean | Extending the existing analytics plugin follows repo guidance and current plugin precedent. | +| YAGNI violations | Clean | Publishing/deletion are explicitly out of scope. | +| Missing failure modes | Fixed | No-credential live apply is now a required verification. | +| Security/privacy | Clean | Secrets are redacted; no user traffic/PII is processed. | +| Infrastructure impact | Clean | Google SaaS resource creation and no-delete rollback are stated. | +| Multi-component validation | Clean | CLI, step, fake SDK, and consumer dry-run are covered. | +| Rollback | Clean | Rollback avoids deleting analytics history. | +| Simpler alternative | Clean | Manual spreadsheet/env IDs considered and rejected. | +| User-intent drift | Clean | Design covers GA/GTM programmatic provisioning and multisite deploy steps. | + +## Plan Phase + +**Status:** PASS after author revision. + +**Findings** + +- Important: audit file promised by design was not task-owned. Fixed in Task 1. +- Important: deployment blocker was described in prose but not verified. Fixed in Task 6. +- Minor: one PR is large but reviewable because it stays within one public plugin and docs; splitting GA and GTM would create cross-PR dependency on shared provider/audit code. + +**Verdict reasoning:** The revised plan covers the design without adding unrelated scope. Verification matches the change classes: unit tests for reconcilers, CLI smoke for command surface, build/test for plugin load, and an explicit blocked-live-apply check before operator credentials exist. diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md b/docs/plans/2026-05-26-google-analytics-provisioning.md index 93fd058..a49dfd3 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md @@ -37,12 +37,14 @@ - Modify: `internal/plugin.go` - Create: `internal/google_provider.go` - Create: `internal/google_provider_test.go` +- Create: `internal/google_audit.go` +- Create: `internal/google_audit_test.go` - Modify: `plugin.json` **Steps:** -1. Write failing tests that `analytics.google_provider` is exposed, registers config by module name, supports env/file/ADC credential inputs, and redacts credential values in validation errors. +1. Write failing tests that `analytics.google_provider` is exposed, registers config by module name, supports env/file/ADC credential inputs, redacts credential values in validation errors, and appends non-secret JSONL audit events. 2. Run `GOWORK=off go test ./internal -run 'TestGoogleProvider|TestPluginExposes' -count=1`; expected: FAIL because module type does not exist. -3. Implement `GoogleProviderConfig`, provider registry, module lifecycle, and plugin `ModuleTypes/CreateModule`. +3. Implement `GoogleProviderConfig`, provider registry, module lifecycle, audit writer, and plugin `ModuleTypes/CreateModule`. 4. Run the focused tests; expected: PASS. 5. Rollback: revert this task commit to remove the module surface and plugin manifest entry. @@ -118,4 +120,5 @@ 3. Run `GOWORK=off go test ./...`; expected: PASS. 4. Run `GOWORK=off go build ./...`; expected: PASS. 5. Run CLI smoke: `GOWORK=off go run ./cmd/workflow-plugin-analytics analytics google ga4 ensure --account accounts/123 --property-name example.com --stream-name example.com --default-uri https://example.com --dry-run`; expected JSON includes `"dry_run":true` and `"measurement_id":""`. -6. Rollback: revert docs/example commit; no live resources touched. +6. Confirm live apply is still blocked by running the same command without `--dry-run` and no credentials; expected non-zero exit and an error that names missing Google credentials without printing any credential value. +7. Rollback: revert docs/example commit; no live resources touched. From 9adee7320974fcefc932586b313b7f631c87218d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 01:54:37 -0400 Subject: [PATCH 03/10] docs: fix provisioning plan manifest --- .../2026-05-26-google-analytics-provisioning.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md b/docs/plans/2026-05-26-google-analytics-provisioning.md index a49dfd3..54a3871 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md @@ -31,7 +31,7 @@ **Status:** Draft -## Task 1: Google Provider Module and Credentials +### Task 1: Google Provider Module and Credentials **Files:** - Modify: `internal/plugin.go` @@ -48,7 +48,7 @@ 4. Run the focused tests; expected: PASS. 5. Rollback: revert this task commit to remove the module surface and plugin manifest entry. -## Task 2: GA4 Web Stream Reconciler +### Task 2: GA4 Web Stream Reconciler **Files:** - Create: `internal/google_ga4.go` @@ -62,7 +62,7 @@ 5. Run the focused tests; expected: PASS. 6. Rollback: revert this task commit; no live resources are touched by tests. -## Task 3: GTM Web Container Reconciler +### Task 3: GTM Web Container Reconciler **Files:** - Create: `internal/google_gtm.go` @@ -76,7 +76,7 @@ 5. Run the focused tests; expected: PASS. 6. Rollback: revert this task commit; no live resources are touched by tests. -## Task 4: CLI JSON Surfaces +### Task 4: CLI JSON Surfaces **Files:** - Modify: `internal/cli.go` @@ -91,7 +91,7 @@ 4. Run the focused tests; expected: PASS. 5. Rollback: revert this task commit; existing `analytics inject` behavior remains in prior commits. -## Task 5: Workflow Ensure Steps +### Task 5: Workflow Ensure Steps **Files:** - Create: `internal/google_steps.go` @@ -107,7 +107,7 @@ 4. Run the focused tests; expected: PASS. 5. Rollback: revert this task commit to remove provisioning step types. -## Task 6: Consumer Deployment Guidance and Verification +### Task 6: Consumer Deployment Guidance and Verification **Files:** - Modify: `README.md` From 6f1a9cca887dea1079d43c263612624bdf9f9f5d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 01:54:45 -0400 Subject: [PATCH 04/10] chore: lock scope for google analytics provisioning --- docs/plans/2026-05-26-google-analytics-provisioning.md | 2 +- .../2026-05-26-google-analytics-provisioning.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-26-google-analytics-provisioning.md.scope-lock diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md b/docs/plans/2026-05-26-google-analytics-provisioning.md index 54a3871..ef114e7 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md @@ -29,7 +29,7 @@ |------|-------|-------|--------| | 1 | Add Google analytics provisioning | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/google-analytics-provisioning | -**Status:** Draft +**Status:** Locked 2026-05-26T05:54:45Z ### Task 1: Google Provider Module and Credentials diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md.scope-lock b/docs/plans/2026-05-26-google-analytics-provisioning.md.scope-lock new file mode 100644 index 0000000..a41e61e --- /dev/null +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md.scope-lock @@ -0,0 +1 @@ +cd2f76f33bf7667ce02056c22f9ecb96efcccc1d9be4196f82785812d2fb19a9 From 2b497b3048a1a589976a83c67f058b80e5e18a00 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:01:07 -0400 Subject: [PATCH 05/10] feat: add google analytics provisioning --- go.mod | 10 + go.sum | 20 ++ internal/cli.go | 130 +++++++++++- internal/cli_test.go | 52 +++++ internal/google_audit.go | 59 ++++++ internal/google_common.go | 120 ++++++++++++ internal/google_ga4.go | 243 +++++++++++++++++++++++ internal/google_ga4_test.go | 116 +++++++++++ internal/google_gtm.go | 282 +++++++++++++++++++++++++++ internal/google_gtm_test.go | 134 +++++++++++++ internal/google_provider.go | 111 +++++++++++ internal/google_provider_test.go | 99 ++++++++++ internal/google_steps.go | 171 ++++++++++++++++ internal/google_test_helpers_test.go | 21 ++ internal/plugin.go | 35 +++- internal/step_test.go | 49 ++++- 16 files changed, 1649 insertions(+), 3 deletions(-) create mode 100644 internal/google_audit.go create mode 100644 internal/google_common.go create mode 100644 internal/google_ga4.go create mode 100644 internal/google_ga4_test.go create mode 100644 internal/google_gtm.go create mode 100644 internal/google_gtm_test.go create mode 100644 internal/google_provider.go create mode 100644 internal/google_provider_test.go create mode 100644 internal/google_steps.go create mode 100644 internal/google_test_helpers_test.go diff --git a/go.mod b/go.mod index 6ebeb1f..058704d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ go 1.26.0 require github.com/GoCodeAlone/workflow v0.62.0 require ( + cloud.google.com/go/analytics v0.35.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect github.com/GoCodeAlone/go-plugin v1.7.0 // indirect @@ -57,7 +61,10 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/cast v1.3.3 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -119,6 +126,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect @@ -138,6 +146,8 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/api v0.280.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/grpc v1.81.1 // indirect diff --git a/go.sum b/go.sum index 2b69e90..3168e08 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +cloud.google.com/go/analytics v0.35.0 h1:GuwmzJHIaQRvtko6g4wW1ngQ7rpDvDLZ8M3iP3kiflU= +cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -144,8 +152,14 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= @@ -346,6 +360,8 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -446,6 +462,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= diff --git a/internal/cli.go b/internal/cli.go index 5723d88..419f260 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -1,6 +1,8 @@ package internal import ( + "context" + "encoding/json" "flag" "fmt" "io" @@ -37,6 +39,8 @@ func (c *CLIProvider) RunCLI(args []string) int { switch args[1] { case "inject": return c.runInject(args[2:]) + case "google": + return c.runGoogle(args[2:]) case "help", "-h", "--help": c.usage() return 0 @@ -47,6 +51,122 @@ func (c *CLIProvider) RunCLI(args []string) int { } } +func (c *CLIProvider) runGoogle(args []string) int { + if len(args) < 2 { + c.googleUsage() + return 2 + } + switch args[0] + "/" + args[1] { + case "ga4/ensure": + return c.runGoogleGA4Ensure(args[2:]) + case "gtm/ensure": + return c.runGoogleGTMEnsure(args[2:]) + default: + fmt.Fprintf(c.stderr, "unknown analytics google subcommand %q\n", args) + c.googleUsage() + return 2 + } +} + +func (c *CLIProvider) runGoogleGA4Ensure(args []string) int { + fs := flag.NewFlagSet("analytics google ga4 ensure", flag.ContinueOnError) + fs.SetOutput(c.stderr) + account := fs.String("account", "", "Google Analytics account resource, accounts/") + propertyName := fs.String("property-name", "", "GA4 property display name") + streamName := fs.String("stream-name", "", "GA4 web stream display name") + defaultURI := fs.String("default-uri", "", "GA4 web stream default URI") + timeZone := fs.String("time-zone", "America/New_York", "GA4 reporting time zone") + currency := fs.String("currency", "USD", "GA4 currency code") + dryRun := fs.Bool("dry-run", false, "Plan changes without calling Google APIs") + credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") + credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") + if err := fs.Parse(args); err != nil { + return 2 + } + provider := googleProvider{config: GoogleProviderConfig{ + CredentialsJSONEnv: *credentialsJSONEnv, + CredentialsFileEnv: *credentialsFileEnv, + }} + client, audit, err := ga4ClientForProvider(context.Background(), provider, *dryRun) + if err != nil { + fmt.Fprintf(c.stderr, "analytics google ga4 ensure: %v\n", err) + return 1 + } + result, err := EnsureGA4WebStream(context.Background(), client, GA4EnsureRequest{ + Account: *account, + PropertyName: *propertyName, + StreamName: *streamName, + DefaultURI: *defaultURI, + TimeZone: *timeZone, + CurrencyCode: *currency, + DryRun: *dryRun, + }) + if err != nil { + fmt.Fprintf(c.stderr, "analytics google ga4 ensure: %v\n", err) + return 1 + } + _ = audit.Append(context.Background(), googleAuditEvent{Action: "ga4.ensure", Account: result.Account, Resource: result.Property, DryRun: result.DryRun, Operations: operationNameStrings(result.Operations)}) + return c.writeJSON(result) +} + +func (c *CLIProvider) runGoogleGTMEnsure(args []string) int { + fs := flag.NewFlagSet("analytics google gtm ensure", flag.ContinueOnError) + fs.SetOutput(c.stderr) + account := fs.String("account", "", "Google Tag Manager account resource, accounts/") + containerName := fs.String("container-name", "", "GTM container display name") + workspaceName := fs.String("workspace-name", "workflow", "GTM workspace display name") + measurementID := fs.String("measurement-id", "", "GA4 measurement ID to wire into Google tag config") + dryRun := fs.Bool("dry-run", false, "Plan changes without calling Google APIs") + credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") + credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") + var domains repeatedFlag + fs.Var(&domains, "domain", "Domain associated with the web container; repeatable") + if err := fs.Parse(args); err != nil { + return 2 + } + provider := googleProvider{config: GoogleProviderConfig{ + CredentialsJSONEnv: *credentialsJSONEnv, + CredentialsFileEnv: *credentialsFileEnv, + }} + client, audit, err := gtmClientForProvider(context.Background(), provider, *dryRun) + if err != nil { + fmt.Fprintf(c.stderr, "analytics google gtm ensure: %v\n", err) + return 1 + } + result, err := EnsureGTMWebContainer(context.Background(), client, GTMEnsureRequest{ + Account: *account, + ContainerName: *containerName, + Domains: domains, + WorkspaceName: *workspaceName, + MeasurementID: *measurementID, + DryRun: *dryRun, + }) + if err != nil { + fmt.Fprintf(c.stderr, "analytics google gtm ensure: %v\n", err) + return 1 + } + _ = audit.Append(context.Background(), googleAuditEvent{Action: "gtm.ensure", Account: result.Account, Resource: result.ContainerPath, DryRun: result.DryRun, Operations: operationNameStrings(result.Operations)}) + return c.writeJSON(result) +} + +func (c *CLIProvider) writeJSON(v any) int { + enc := json.NewEncoder(c.stdout) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + fmt.Fprintf(c.stderr, "analytics: encode JSON: %v\n", err) + return 1 + } + return 0 +} + +type repeatedFlag []string + +func (f *repeatedFlag) String() string { return fmt.Sprint([]string(*f)) } +func (f *repeatedFlag) Set(value string) error { + *f = append(*f, value) + return nil +} + func (c *CLIProvider) runInject(args []string) int { fs := flag.NewFlagSet("analytics inject", flag.ContinueOnError) fs.SetOutput(c.stderr) @@ -90,5 +210,13 @@ func (c *CLIProvider) usage() { wfctl analytics inject --provider google-analytics --tag-id-env GOOGLE_TAG_ID --dir dist Subcommands: - analytics inject Inject provider snippets into rendered HTML assets`) + analytics inject Inject provider snippets into rendered HTML assets + analytics google ga4 ensure Ensure GA4 property + web stream + analytics google gtm ensure Ensure GTM web container + workspace`) +} + +func (c *CLIProvider) googleUsage() { + fmt.Fprintln(c.stderr, `Usage: + wfctl analytics google ga4 ensure --account accounts/123 --property-name example.com --stream-name example.com --default-uri https://example.com --dry-run + wfctl analytics google gtm ensure --account accounts/456 --container-name example.com --domain example.com --dry-run`) } diff --git a/internal/cli_test.go b/internal/cli_test.go index f9d27a3..437de3e 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -2,6 +2,7 @@ package internal import ( "bytes" + "encoding/json" "os" "path/filepath" "strings" @@ -60,3 +61,54 @@ func TestCLIInjectEmptyEnvNoop(t *testing.T) { t.Fatalf("unexpected stdout: %s", stdout.String()) } } + +func TestCLIAnalyticsGoogleGA4EnsureDryRun(t *testing.T) { + var stdout, stderr bytes.Buffer + code := newCLIProvider(&stdout, &stderr).RunCLI([]string{ + "analytics", "google", "ga4", "ensure", + "--account", "accounts/123", + "--property-name", "example.com", + "--stream-name", "example.com", + "--default-uri", "https://example.com", + "--dry-run", + }) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + var result GA4EnsureResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if !result.DryRun { + t.Fatalf("dry_run=false: %#v", result) + } + if got := operationNames(result.Operations); !sameStrings(got, []string{"create_property", "create_web_data_stream"}) { + t.Fatalf("operations = %v", got) + } +} + +func TestCLIAnalyticsGoogleGTMEnsureDryRun(t *testing.T) { + var stdout, stderr bytes.Buffer + code := newCLIProvider(&stdout, &stderr).RunCLI([]string{ + "analytics", "google", "gtm", "ensure", + "--account", "accounts/456", + "--container-name", "example.com", + "--domain", "example.com", + "--workspace-name", "workflow", + "--measurement-id", "G-ABC123", + "--dry-run", + }) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + var result GTMEnsureResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("json output: %v\n%s", err, stdout.String()) + } + if !result.DryRun { + t.Fatalf("dry_run=false: %#v", result) + } + if got := operationNames(result.Operations); !sameStrings(got, []string{"create_container", "create_workspace", "create_gtag_config"}) { + t.Fatalf("operations = %v", got) + } +} diff --git a/internal/google_audit.go b/internal/google_audit.go new file mode 100644 index 0000000..4c4c540 --- /dev/null +++ b/internal/google_audit.go @@ -0,0 +1,59 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type googleAuditEvent struct { + Time time.Time `json:"time"` + Action string `json:"action"` + Account string `json:"account,omitempty"` + Resource string `json:"resource,omitempty"` + DryRun bool `json:"dry_run"` + Operations []string `json:"operations,omitempty"` +} + +type googleAuditWriter struct { + path string +} + +func defaultGoogleAuditPath() string { + if stateHome := os.Getenv("XDG_STATE_HOME"); stateHome != "" { + return filepath.Join(stateHome, "wfctl", "plugins", "analytics", "google-audit.jsonl") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".local", "state", "wfctl", "plugins", "analytics", "google-audit.jsonl") +} + +func (w googleAuditWriter) Append(_ context.Context, event googleAuditEvent) error { + if w.path == "" { + return nil + } + if event.Time.IsZero() { + event.Time = time.Now().UTC() + } + if err := os.MkdirAll(filepath.Dir(w.path), 0700); err != nil { + return fmt.Errorf("analytics google audit: create audit dir: %w", err) + } + f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("analytics google audit: open audit file: %w", err) + } + defer f.Close() + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("analytics google audit: marshal event: %w", err) + } + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("analytics google audit: write event: %w", err) + } + return nil +} diff --git a/internal/google_common.go b/internal/google_common.go new file mode 100644 index 0000000..40c616b --- /dev/null +++ b/internal/google_common.go @@ -0,0 +1,120 @@ +package internal + +import ( + "fmt" + "net" + "net/url" + "sort" + "strings" +) + +// Operation describes one reconciliation action that was reused, planned, or +// performed. It is JSON-friendly for CLI/step outputs and audit logs. +type Operation struct { + Name string `json:"name"` + Status string `json:"status"` +} + +func planned(name string, dryRun bool) Operation { + if dryRun { + return Operation{Name: name, Status: "planned"} + } + return Operation{Name: name, Status: "created"} +} + +func reused(name string) Operation { + return Operation{Name: name, Status: "reused"} +} + +func operationNameStrings(ops []Operation) []string { + out := make([]string, 0, len(ops)) + for _, op := range ops { + out = append(out, op.Name) + } + return out +} + +func validateGoogleAccount(account string) error { + if !strings.HasPrefix(account, "accounts/") || strings.TrimPrefix(account, "accounts/") == "" { + return fmt.Errorf("account must use accounts/ format") + } + return nil +} + +func validateWebURI(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return fmt.Errorf("default_uri must be an http(s) URL") + } + return nil +} + +func normalizeDomains(values []string) ([]string, error) { + seen := make(map[string]struct{}, len(values)) + var out []string + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + if strings.Contains(value, "://") { + u, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("invalid domain %q", value) + } + value = u.Host + } + if strings.Contains(value, " ") || net.ParseIP(value) != nil { + return nil, fmt.Errorf("invalid domain %q", value) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + if len(out) == 0 { + return nil, fmt.Errorf("at least one domain is required") + } + return out, nil +} + +func stringSliceConfig(config map[string]any, key string) []string { + if config == nil { + return nil + } + switch v := config[key].(type) { + case []string: + return append([]string(nil), v...) + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + case string: + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if s := strings.TrimSpace(part); s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func boolConfig(config map[string]any, key string, fallback bool) bool { + if config == nil { + return fallback + } + if v, ok := config[key].(bool); ok { + return v + } + return fallback +} diff --git a/internal/google_ga4.go b/internal/google_ga4.go new file mode 100644 index 0000000..41d7b3e --- /dev/null +++ b/internal/google_ga4.go @@ -0,0 +1,243 @@ +package internal + +import ( + "context" + "fmt" + "strings" + + admin "cloud.google.com/go/analytics/admin/apiv1alpha" + "cloud.google.com/go/analytics/admin/apiv1alpha/adminpb" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +type GA4EnsureRequest struct { + Account string `json:"account"` + PropertyName string `json:"property_name"` + StreamName string `json:"stream_name"` + DefaultURI string `json:"default_uri"` + TimeZone string `json:"time_zone,omitempty"` + CurrencyCode string `json:"currency_code,omitempty"` + DryRun bool `json:"dry_run"` +} + +type GA4EnsureResult struct { + Account string `json:"account"` + Property string `json:"property"` + DataStream string `json:"data_stream"` + MeasurementID string `json:"measurement_id"` + DryRun bool `json:"dry_run"` + Operations []Operation `json:"operations"` +} + +type GA4Property struct { + Name string + DisplayName string +} + +type GA4DataStream struct { + Name string + DisplayName string + DefaultURI string + MeasurementID string +} + +type GA4CreatePropertyRequest struct { + Account string + DisplayName string + TimeZone string + Currency string +} + +type GA4CreateWebDataStreamRequest struct { + Property string + DisplayName string + DefaultURI string +} + +type GA4AdminClient interface { + ListProperties(ctx context.Context, account string) ([]GA4Property, error) + CreateProperty(ctx context.Context, req GA4CreatePropertyRequest) (GA4Property, error) + ListDataStreams(ctx context.Context, property string) ([]GA4DataStream, error) + CreateWebDataStream(ctx context.Context, req GA4CreateWebDataStreamRequest) (GA4DataStream, error) +} + +func EnsureGA4WebStream(ctx context.Context, client GA4AdminClient, req GA4EnsureRequest) (GA4EnsureResult, error) { + req.PropertyName = strings.TrimSpace(req.PropertyName) + req.StreamName = strings.TrimSpace(req.StreamName) + req.DefaultURI = strings.TrimSpace(req.DefaultURI) + if err := validateGoogleAccount(req.Account); err != nil { + return GA4EnsureResult{}, err + } + if req.PropertyName == "" { + return GA4EnsureResult{}, fmt.Errorf("property_name is required") + } + if req.StreamName == "" { + req.StreamName = req.PropertyName + } + if err := validateWebURI(req.DefaultURI); err != nil { + return GA4EnsureResult{}, err + } + result := GA4EnsureResult{Account: req.Account, DryRun: req.DryRun} + + var property GA4Property + if !req.DryRun { + if client == nil { + return GA4EnsureResult{}, fmt.Errorf("google credentials are required for live GA4 ensure") + } + properties, err := client.ListProperties(ctx, req.Account) + if err != nil { + return result, err + } + for _, candidate := range properties { + if candidate.DisplayName == req.PropertyName { + property = candidate + result.Operations = append(result.Operations, reused("reuse_property")) + break + } + } + } + if property.Name == "" { + result.Operations = append(result.Operations, planned("create_property", req.DryRun)) + if req.DryRun { + result.Property = "" + result.Operations = append(result.Operations, planned("create_web_data_stream", true)) + return result, nil + } + created, err := client.CreateProperty(ctx, GA4CreatePropertyRequest{ + Account: req.Account, + DisplayName: req.PropertyName, + TimeZone: defaultString(req.TimeZone, "America/New_York"), + Currency: defaultString(req.CurrencyCode, "USD"), + }) + if err != nil { + return result, err + } + property = created + } + result.Property = property.Name + + var stream GA4DataStream + streams, err := client.ListDataStreams(ctx, property.Name) + if err != nil { + return result, err + } + for _, candidate := range streams { + if candidate.DisplayName == req.StreamName || candidate.DefaultURI == req.DefaultURI { + stream = candidate + result.Operations = append(result.Operations, reused("reuse_web_data_stream")) + break + } + } + if stream.Name == "" { + result.Operations = append(result.Operations, planned("create_web_data_stream", false)) + created, err := client.CreateWebDataStream(ctx, GA4CreateWebDataStreamRequest{ + Property: property.Name, + DisplayName: req.StreamName, + DefaultURI: req.DefaultURI, + }) + if err != nil { + return result, err + } + stream = created + } + result.DataStream = stream.Name + result.MeasurementID = stream.MeasurementID + return result, nil +} + +type googleGA4SDKClient struct { + client *admin.AnalyticsAdminClient +} + +func newGoogleGA4SDKClient(ctx context.Context, opts ...option.ClientOption) (*googleGA4SDKClient, error) { + client, err := admin.NewAnalyticsAdminClient(ctx, opts...) + if err != nil { + return nil, err + } + return &googleGA4SDKClient{client: client}, nil +} + +func (c *googleGA4SDKClient) ListProperties(ctx context.Context, account string) ([]GA4Property, error) { + iter := c.client.ListProperties(ctx, &adminpb.ListPropertiesRequest{ + Filter: "parent:" + account, + PageSize: 200, + }) + var out []GA4Property + for { + property, err := iter.Next() + if err == iterator.Done { + return out, nil + } + if err != nil { + return nil, err + } + out = append(out, GA4Property{Name: property.GetName(), DisplayName: property.GetDisplayName()}) + } +} + +func (c *googleGA4SDKClient) CreateProperty(ctx context.Context, req GA4CreatePropertyRequest) (GA4Property, error) { + property, err := c.client.CreateProperty(ctx, &adminpb.CreatePropertyRequest{Property: &adminpb.Property{ + Parent: req.Account, + DisplayName: req.DisplayName, + TimeZone: req.TimeZone, + CurrencyCode: req.Currency, + }}) + if err != nil { + return GA4Property{}, err + } + return GA4Property{Name: property.GetName(), DisplayName: property.GetDisplayName()}, nil +} + +func (c *googleGA4SDKClient) ListDataStreams(ctx context.Context, property string) ([]GA4DataStream, error) { + iter := c.client.ListDataStreams(ctx, &adminpb.ListDataStreamsRequest{ + Parent: property, + PageSize: 200, + }) + var out []GA4DataStream + for { + stream, err := iter.Next() + if err == iterator.Done { + return out, nil + } + if err != nil { + return nil, err + } + web := stream.GetWebStreamData() + out = append(out, GA4DataStream{ + Name: stream.GetName(), + DisplayName: stream.GetDisplayName(), + DefaultURI: web.GetDefaultUri(), + MeasurementID: web.GetMeasurementId(), + }) + } +} + +func (c *googleGA4SDKClient) CreateWebDataStream(ctx context.Context, req GA4CreateWebDataStreamRequest) (GA4DataStream, error) { + stream, err := c.client.CreateDataStream(ctx, &adminpb.CreateDataStreamRequest{ + Parent: req.Property, + DataStream: &adminpb.DataStream{ + Type: adminpb.DataStream_WEB_DATA_STREAM, + DisplayName: req.DisplayName, + StreamData: &adminpb.DataStream_WebStreamData_{WebStreamData: &adminpb.DataStream_WebStreamData{DefaultUri: req.DefaultURI}}, + }, + }) + if err != nil { + return GA4DataStream{}, err + } + web := stream.GetWebStreamData() + return GA4DataStream{ + Name: stream.GetName(), + DisplayName: stream.GetDisplayName(), + DefaultURI: web.GetDefaultUri(), + MeasurementID: web.GetMeasurementId(), + }, nil +} + +func defaultString(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/internal/google_ga4_test.go b/internal/google_ga4_test.go new file mode 100644 index 0000000..868a0d9 --- /dev/null +++ b/internal/google_ga4_test.go @@ -0,0 +1,116 @@ +package internal + +import ( + "context" + "testing" +) + +func TestEnsureGA4DryRunPlansCreates(t *testing.T) { + result, err := EnsureGA4WebStream(context.Background(), nil, GA4EnsureRequest{ + Account: "accounts/123", + PropertyName: "example.com", + StreamName: "example.com", + DefaultURI: "https://example.com", + DryRun: true, + }) + if err != nil { + t.Fatalf("EnsureGA4WebStream: %v", err) + } + if !result.DryRun { + t.Fatal("dry_run = false") + } + if got := operationNames(result.Operations); !sameStrings(got, []string{"create_property", "create_web_data_stream"}) { + t.Fatalf("operations = %v", got) + } +} + +func TestEnsureGA4ReusesExistingPropertyAndStream(t *testing.T) { + client := &fakeGA4AdminClient{ + properties: []GA4Property{{Name: "properties/1", DisplayName: "example.com"}}, + streams: map[string][]GA4DataStream{ + "properties/1": {{Name: "properties/1/dataStreams/2", DisplayName: "example.com", DefaultURI: "https://example.com", MeasurementID: "G-ABC123"}}, + }, + } + result, err := EnsureGA4WebStream(context.Background(), client, GA4EnsureRequest{ + Account: "accounts/123", + PropertyName: "example.com", + StreamName: "example.com", + DefaultURI: "https://example.com", + }) + if err != nil { + t.Fatalf("EnsureGA4WebStream: %v", err) + } + if result.Property != "properties/1" || result.DataStream != "properties/1/dataStreams/2" || result.MeasurementID != "G-ABC123" { + t.Fatalf("unexpected result: %#v", result) + } + if client.createdProperties != 0 || client.createdStreams != 0 { + t.Fatalf("created property=%d stream=%d", client.createdProperties, client.createdStreams) + } +} + +func TestEnsureGA4CreatesMissingResources(t *testing.T) { + client := &fakeGA4AdminClient{} + result, err := EnsureGA4WebStream(context.Background(), client, GA4EnsureRequest{ + Account: "accounts/123", + PropertyName: "example.com", + StreamName: "example.com", + DefaultURI: "https://example.com", + }) + if err != nil { + t.Fatalf("EnsureGA4WebStream: %v", err) + } + if result.Property == "" || result.DataStream == "" || result.MeasurementID == "" { + t.Fatalf("missing created IDs: %#v", result) + } + if got := operationNames(result.Operations); !sameStrings(got, []string{"create_property", "create_web_data_stream"}) { + t.Fatalf("operations = %v", got) + } +} + +func TestEnsureGA4RejectsInvalidInput(t *testing.T) { + _, err := EnsureGA4WebStream(context.Background(), nil, GA4EnsureRequest{ + Account: "123", + PropertyName: "example.com", + StreamName: "example.com", + DefaultURI: "ftp://example.com", + DryRun: true, + }) + if err == nil { + t.Fatal("expected validation error") + } +} + +type fakeGA4AdminClient struct { + properties []GA4Property + streams map[string][]GA4DataStream + createdProperties int + createdStreams int +} + +func (f *fakeGA4AdminClient) ListProperties(_ context.Context, account string) ([]GA4Property, error) { + return f.properties, nil +} + +func (f *fakeGA4AdminClient) CreateProperty(_ context.Context, req GA4CreatePropertyRequest) (GA4Property, error) { + f.createdProperties++ + p := GA4Property{Name: "properties/created", DisplayName: req.DisplayName} + f.properties = append(f.properties, p) + return p, nil +} + +func (f *fakeGA4AdminClient) ListDataStreams(_ context.Context, property string) ([]GA4DataStream, error) { + if f.streams == nil { + return nil, nil + } + return f.streams[property], nil +} + +func (f *fakeGA4AdminClient) CreateWebDataStream(_ context.Context, req GA4CreateWebDataStreamRequest) (GA4DataStream, error) { + f.createdStreams++ + s := GA4DataStream{Name: req.Property + "/dataStreams/created", DisplayName: req.DisplayName, DefaultURI: req.DefaultURI, MeasurementID: "G-CREATED"} + if f.streams == nil { + f.streams = make(map[string][]GA4DataStream) + } + f.streams[req.Property] = append(f.streams[req.Property], s) + return s, nil +} diff --git a/internal/google_gtm.go b/internal/google_gtm.go new file mode 100644 index 0000000..3cc44e8 --- /dev/null +++ b/internal/google_gtm.go @@ -0,0 +1,282 @@ +package internal + +import ( + "context" + "fmt" + "sort" + "strings" + + "google.golang.org/api/option" + tagmanager "google.golang.org/api/tagmanager/v2" +) + +type GTMEnsureRequest struct { + Account string `json:"account"` + ContainerName string `json:"container_name"` + Domains []string `json:"domains"` + WorkspaceName string `json:"workspace_name"` + MeasurementID string `json:"measurement_id,omitempty"` + DryRun bool `json:"dry_run"` +} + +type GTMEnsureResult struct { + Account string `json:"account"` + ContainerPath string `json:"container_path"` + ContainerID string `json:"container_id"` + PublicID string `json:"public_id"` + WorkspacePath string `json:"workspace_path"` + GtagConfigPath string `json:"gtag_config_path,omitempty"` + DryRun bool `json:"dry_run"` + Operations []Operation `json:"operations"` +} + +type GTMContainer struct { + Path string + ID string + Name string + PublicID string + Domains []string +} + +type GTMWorkspace struct { + Path string + Name string +} + +type GTMGtagConfig struct { + Path string + MeasurementID string +} + +type GTMCreateContainerRequest struct { + Account string + Name string + Domains []string +} + +type GTMCreateWorkspaceRequest struct { + ContainerPath string + Name string +} + +type GTMCreateGtagConfigRequest struct { + WorkspacePath string + MeasurementID string +} + +type TagManagerClient interface { + ListContainers(ctx context.Context, account string) ([]GTMContainer, error) + CreateWebContainer(ctx context.Context, req GTMCreateContainerRequest) (GTMContainer, error) + ListWorkspaces(ctx context.Context, containerPath string) ([]GTMWorkspace, error) + CreateWorkspace(ctx context.Context, req GTMCreateWorkspaceRequest) (GTMWorkspace, error) + ListGtagConfigs(ctx context.Context, workspacePath string) ([]GTMGtagConfig, error) + CreateGtagConfig(ctx context.Context, req GTMCreateGtagConfigRequest) (GTMGtagConfig, error) +} + +func EnsureGTMWebContainer(ctx context.Context, client TagManagerClient, req GTMEnsureRequest) (GTMEnsureResult, error) { + req.ContainerName = strings.TrimSpace(req.ContainerName) + req.WorkspaceName = defaultString(req.WorkspaceName, "workflow") + req.MeasurementID = strings.TrimSpace(req.MeasurementID) + if err := validateGoogleAccount(req.Account); err != nil { + return GTMEnsureResult{}, err + } + if req.ContainerName == "" { + return GTMEnsureResult{}, fmt.Errorf("container_name is required") + } + domains, err := normalizeDomains(req.Domains) + if err != nil { + return GTMEnsureResult{}, err + } + result := GTMEnsureResult{Account: req.Account, DryRun: req.DryRun} + + var container GTMContainer + if !req.DryRun { + if client == nil { + return GTMEnsureResult{}, fmt.Errorf("google credentials are required for live GTM ensure") + } + containers, err := client.ListContainers(ctx, req.Account) + if err != nil { + return result, err + } + for _, candidate := range containers { + if candidate.Name == req.ContainerName || sameDomainSet(candidate.Domains, domains) { + container = candidate + result.Operations = append(result.Operations, reused("reuse_container")) + break + } + } + } + if container.Path == "" { + result.Operations = append(result.Operations, planned("create_container", req.DryRun)) + if req.DryRun { + result.Operations = append(result.Operations, planned("create_workspace", true)) + if req.MeasurementID != "" { + result.Operations = append(result.Operations, planned("create_gtag_config", true)) + } + return result, nil + } + created, err := client.CreateWebContainer(ctx, GTMCreateContainerRequest{Account: req.Account, Name: req.ContainerName, Domains: domains}) + if err != nil { + return result, err + } + container = created + } + result.ContainerPath = container.Path + result.ContainerID = container.ID + result.PublicID = container.PublicID + + var workspace GTMWorkspace + workspaces, err := client.ListWorkspaces(ctx, container.Path) + if err != nil { + return result, err + } + for _, candidate := range workspaces { + if candidate.Name == req.WorkspaceName { + workspace = candidate + result.Operations = append(result.Operations, reused("reuse_workspace")) + break + } + } + if workspace.Path == "" { + result.Operations = append(result.Operations, planned("create_workspace", false)) + created, err := client.CreateWorkspace(ctx, GTMCreateWorkspaceRequest{ContainerPath: container.Path, Name: req.WorkspaceName}) + if err != nil { + return result, err + } + workspace = created + } + result.WorkspacePath = workspace.Path + + if req.MeasurementID != "" { + configs, err := client.ListGtagConfigs(ctx, workspace.Path) + if err != nil { + return result, err + } + for _, candidate := range configs { + if candidate.MeasurementID == req.MeasurementID { + result.GtagConfigPath = candidate.Path + result.Operations = append(result.Operations, reused("reuse_gtag_config")) + return result, nil + } + } + result.Operations = append(result.Operations, planned("create_gtag_config", false)) + created, err := client.CreateGtagConfig(ctx, GTMCreateGtagConfigRequest{WorkspacePath: workspace.Path, MeasurementID: req.MeasurementID}) + if err != nil { + return result, err + } + result.GtagConfigPath = created.Path + } + return result, nil +} + +type googleTagManagerSDKClient struct { + service *tagmanager.Service +} + +func newGoogleTagManagerSDKClient(ctx context.Context, opts ...option.ClientOption) (*googleTagManagerSDKClient, error) { + service, err := tagmanager.NewService(ctx, opts...) + if err != nil { + return nil, err + } + return &googleTagManagerSDKClient{service: service}, nil +} + +func (c *googleTagManagerSDKClient) ListContainers(ctx context.Context, account string) ([]GTMContainer, error) { + resp, err := c.service.Accounts.Containers.List(account).Context(ctx).Do() + if err != nil { + return nil, err + } + var out []GTMContainer + for _, item := range resp.Container { + out = append(out, GTMContainer{ + Path: item.Path, + ID: item.ContainerId, + Name: item.Name, + PublicID: item.PublicId, + Domains: item.DomainName, + }) + } + return out, nil +} + +func (c *googleTagManagerSDKClient) CreateWebContainer(ctx context.Context, req GTMCreateContainerRequest) (GTMContainer, error) { + item, err := c.service.Accounts.Containers.Create(req.Account, &tagmanager.Container{ + Name: req.Name, + DomainName: req.Domains, + UsageContext: []string{"web"}, + }).Context(ctx).Do() + if err != nil { + return GTMContainer{}, err + } + return GTMContainer{Path: item.Path, ID: item.ContainerId, Name: item.Name, PublicID: item.PublicId, Domains: item.DomainName}, nil +} + +func (c *googleTagManagerSDKClient) ListWorkspaces(ctx context.Context, containerPath string) ([]GTMWorkspace, error) { + resp, err := c.service.Accounts.Containers.Workspaces.List(containerPath).Context(ctx).Do() + if err != nil { + return nil, err + } + var out []GTMWorkspace + for _, item := range resp.Workspace { + out = append(out, GTMWorkspace{Path: item.Path, Name: item.Name}) + } + return out, nil +} + +func (c *googleTagManagerSDKClient) CreateWorkspace(ctx context.Context, req GTMCreateWorkspaceRequest) (GTMWorkspace, error) { + item, err := c.service.Accounts.Containers.Workspaces.Create(req.ContainerPath, &tagmanager.Workspace{Name: req.Name}).Context(ctx).Do() + if err != nil { + return GTMWorkspace{}, err + } + return GTMWorkspace{Path: item.Path, Name: item.Name}, nil +} + +func (c *googleTagManagerSDKClient) ListGtagConfigs(ctx context.Context, workspacePath string) ([]GTMGtagConfig, error) { + resp, err := c.service.Accounts.Containers.Workspaces.GtagConfig.List(workspacePath).Context(ctx).Do() + if err != nil { + return nil, err + } + var out []GTMGtagConfig + for _, item := range resp.GtagConfig { + out = append(out, GTMGtagConfig{Path: item.Path, MeasurementID: gtagMeasurementID(item)}) + } + return out, nil +} + +func (c *googleTagManagerSDKClient) CreateGtagConfig(ctx context.Context, req GTMCreateGtagConfigRequest) (GTMGtagConfig, error) { + item, err := c.service.Accounts.Containers.Workspaces.GtagConfig.Create(req.WorkspacePath, &tagmanager.GtagConfig{ + Type: "ga4", + Parameter: []*tagmanager.Parameter{ + {Type: "template", Key: "measurementId", Value: req.MeasurementID}, + }, + }).Context(ctx).Do() + if err != nil { + return GTMGtagConfig{}, err + } + return GTMGtagConfig{Path: item.Path, MeasurementID: gtagMeasurementID(item)}, nil +} + +func gtagMeasurementID(config *tagmanager.GtagConfig) string { + for _, param := range config.Parameter { + if param.Key == "measurementId" || param.Key == "measurement_id" { + return param.Value + } + } + return "" +} + +func sameDomainSet(a, b []string) bool { + aa, errA := normalizeDomains(a) + bb, errB := normalizeDomains(b) + if errA != nil || errB != nil || len(aa) != len(bb) { + return false + } + sort.Strings(aa) + sort.Strings(bb) + for i := range aa { + if aa[i] != bb[i] { + return false + } + } + return true +} diff --git a/internal/google_gtm_test.go b/internal/google_gtm_test.go new file mode 100644 index 0000000..244bbed --- /dev/null +++ b/internal/google_gtm_test.go @@ -0,0 +1,134 @@ +package internal + +import ( + "context" + "testing" +) + +func TestEnsureGTMDryRunPlansCreates(t *testing.T) { + result, err := EnsureGTMWebContainer(context.Background(), nil, GTMEnsureRequest{ + Account: "accounts/456", + ContainerName: "example.com", + Domains: []string{"example.com"}, + WorkspaceName: "workflow", + MeasurementID: "G-ABC123", + DryRun: true, + }) + if err != nil { + t.Fatalf("EnsureGTMWebContainer: %v", err) + } + if got := operationNames(result.Operations); !sameStrings(got, []string{"create_container", "create_workspace", "create_gtag_config"}) { + t.Fatalf("operations = %v", got) + } +} + +func TestEnsureGTMReusesExistingResources(t *testing.T) { + client := &fakeTagManagerClient{ + containers: []GTMContainer{{Path: "accounts/456/containers/1", Name: "example.com", PublicID: "GTM-ABC", Domains: []string{"example.com"}}}, + workspaces: map[string][]GTMWorkspace{ + "accounts/456/containers/1": {{Path: "accounts/456/containers/1/workspaces/2", Name: "workflow"}}, + }, + gtagConfigs: map[string][]GTMGtagConfig{ + "accounts/456/containers/1/workspaces/2": {{Path: "accounts/456/containers/1/workspaces/2/gtag_config/3", MeasurementID: "G-ABC123"}}, + }, + } + result, err := EnsureGTMWebContainer(context.Background(), client, GTMEnsureRequest{ + Account: "accounts/456", + ContainerName: "example.com", + Domains: []string{"example.com"}, + WorkspaceName: "workflow", + MeasurementID: "G-ABC123", + }) + if err != nil { + t.Fatalf("EnsureGTMWebContainer: %v", err) + } + if result.ContainerPath != "accounts/456/containers/1" || result.PublicID != "GTM-ABC" || result.WorkspacePath == "" { + t.Fatalf("unexpected result: %#v", result) + } + if client.createdContainers != 0 || client.createdWorkspaces != 0 || client.createdGtagConfigs != 0 { + t.Fatalf("created container=%d workspace=%d config=%d", client.createdContainers, client.createdWorkspaces, client.createdGtagConfigs) + } +} + +func TestEnsureGTMCreatesMissingResources(t *testing.T) { + client := &fakeTagManagerClient{} + result, err := EnsureGTMWebContainer(context.Background(), client, GTMEnsureRequest{ + Account: "accounts/456", + ContainerName: "example.com", + Domains: []string{"example.com"}, + WorkspaceName: "workflow", + MeasurementID: "G-ABC123", + }) + if err != nil { + t.Fatalf("EnsureGTMWebContainer: %v", err) + } + if result.ContainerPath == "" || result.PublicID == "" || result.WorkspacePath == "" || result.GtagConfigPath == "" { + t.Fatalf("missing created IDs: %#v", result) + } +} + +func TestEnsureGTMRejectsInvalidInput(t *testing.T) { + _, err := EnsureGTMWebContainer(context.Background(), nil, GTMEnsureRequest{ + Account: "bad", + ContainerName: "example.com", + Domains: []string{"not a host"}, + DryRun: true, + }) + if err == nil { + t.Fatal("expected validation error") + } +} + +type fakeTagManagerClient struct { + containers []GTMContainer + workspaces map[string][]GTMWorkspace + gtagConfigs map[string][]GTMGtagConfig + createdContainers int + createdWorkspaces int + createdGtagConfigs int +} + +func (f *fakeTagManagerClient) ListContainers(_ context.Context, account string) ([]GTMContainer, error) { + return f.containers, nil +} + +func (f *fakeTagManagerClient) CreateWebContainer(_ context.Context, req GTMCreateContainerRequest) (GTMContainer, error) { + f.createdContainers++ + c := GTMContainer{Path: req.Account + "/containers/created", Name: req.Name, PublicID: "GTM-CREATED", Domains: req.Domains} + f.containers = append(f.containers, c) + return c, nil +} + +func (f *fakeTagManagerClient) ListWorkspaces(_ context.Context, containerPath string) ([]GTMWorkspace, error) { + if f.workspaces == nil { + return nil, nil + } + return f.workspaces[containerPath], nil +} + +func (f *fakeTagManagerClient) CreateWorkspace(_ context.Context, req GTMCreateWorkspaceRequest) (GTMWorkspace, error) { + f.createdWorkspaces++ + w := GTMWorkspace{Path: req.ContainerPath + "/workspaces/created", Name: req.Name} + if f.workspaces == nil { + f.workspaces = make(map[string][]GTMWorkspace) + } + f.workspaces[req.ContainerPath] = append(f.workspaces[req.ContainerPath], w) + return w, nil +} + +func (f *fakeTagManagerClient) ListGtagConfigs(_ context.Context, workspacePath string) ([]GTMGtagConfig, error) { + if f.gtagConfigs == nil { + return nil, nil + } + return f.gtagConfigs[workspacePath], nil +} + +func (f *fakeTagManagerClient) CreateGtagConfig(_ context.Context, req GTMCreateGtagConfigRequest) (GTMGtagConfig, error) { + f.createdGtagConfigs++ + c := GTMGtagConfig{Path: req.WorkspacePath + "/gtag_config/created", MeasurementID: req.MeasurementID} + if f.gtagConfigs == nil { + f.gtagConfigs = make(map[string][]GTMGtagConfig) + } + f.gtagConfigs[req.WorkspacePath] = append(f.gtagConfigs[req.WorkspacePath], c) + return c, nil +} diff --git a/internal/google_provider.go b/internal/google_provider.go new file mode 100644 index 0000000..4b87ce9 --- /dev/null +++ b/internal/google_provider.go @@ -0,0 +1,111 @@ +package internal + +import ( + "context" + "fmt" + "os" + "sync" + + "google.golang.org/api/option" +) + +type GoogleProviderConfig struct { + CredentialsJSON string + CredentialsJSONEnv string + CredentialsFile string + CredentialsFileEnv string + AnalyticsAccount string + TagManagerAccount string + AuditPath string +} + +type googleProvider struct { + config GoogleProviderConfig +} + +type googleProviderModule struct { + name string + provider googleProvider +} + +var googleProviders = struct { + sync.RWMutex + byName map[string]googleProvider +}{byName: make(map[string]googleProvider)} + +func newGoogleProviderModule(name string, config map[string]any) (*googleProviderModule, error) { + cfg := GoogleProviderConfig{ + CredentialsJSON: stringValue(config, "credentials_json"), + CredentialsJSONEnv: stringValue(config, "credentials_json_env"), + CredentialsFile: stringValue(config, "credentials_file"), + CredentialsFileEnv: stringValue(config, "credentials_file_env"), + AnalyticsAccount: stringValue(config, "analytics_account"), + TagManagerAccount: stringValue(config, "tag_manager_account"), + AuditPath: stringValue(config, "audit_path"), + } + if cfg.AuditPath == "" { + cfg.AuditPath = defaultGoogleAuditPath() + } + return &googleProviderModule{name: name, provider: googleProvider{config: cfg}}, nil +} + +func (m *googleProviderModule) Init() error { + googleProviders.Lock() + defer googleProviders.Unlock() + googleProviders.byName[m.name] = m.provider + return nil +} + +func (m *googleProviderModule) Start(_ context.Context) error { return nil } + +func (m *googleProviderModule) Stop(_ context.Context) error { + googleProviders.Lock() + defer googleProviders.Unlock() + delete(googleProviders.byName, m.name) + return nil +} + +func getGoogleProvider(name string) (googleProvider, bool) { + if name == "" { + name = "google" + } + googleProviders.RLock() + defer googleProviders.RUnlock() + provider, ok := googleProviders.byName[name] + return provider, ok +} + +func (p googleProvider) clientOptions() ([]option.ClientOption, bool, error) { + if p.config.CredentialsJSON != "" { + return []option.ClientOption{option.WithCredentialsJSON([]byte(p.config.CredentialsJSON))}, true, nil + } + if p.config.CredentialsJSONEnv != "" { + value := os.Getenv(p.config.CredentialsJSONEnv) + if value == "" { + return nil, false, fmt.Errorf("google credentials JSON env %q is empty", p.config.CredentialsJSONEnv) + } + return []option.ClientOption{option.WithCredentialsJSON([]byte(value))}, true, nil + } + if p.config.CredentialsFile != "" { + if _, err := os.Stat(p.config.CredentialsFile); err != nil { + return nil, false, fmt.Errorf("google credentials file is not readable") + } + return []option.ClientOption{option.WithCredentialsFile(p.config.CredentialsFile)}, true, nil + } + if p.config.CredentialsFileEnv != "" { + path := os.Getenv(p.config.CredentialsFileEnv) + if path == "" { + return nil, false, fmt.Errorf("google credentials file env %q is empty", p.config.CredentialsFileEnv) + } + if _, err := os.Stat(path); err != nil { + return nil, false, fmt.Errorf("google credentials file is not readable") + } + return []option.ClientOption{option.WithCredentialsFile(path)}, true, nil + } + return nil, false, nil +} + +func stringValue(config map[string]any, key string) string { + v, _ := stringConfig(config, key) + return v +} diff --git a/internal/google_provider_test.go b/internal/google_provider_test.go new file mode 100644 index 0000000..e3f8880 --- /dev/null +++ b/internal/google_provider_test.go @@ -0,0 +1,99 @@ +package internal + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +func TestPluginExposesGoogleProviderModule(t *testing.T) { + p := NewPlugin() + moduleProvider, ok := p.(sdk.ModuleProvider) + if !ok { + t.Fatal("plugin does not expose module provider methods") + } + if got := moduleProvider.ModuleTypes(); len(got) != 1 || got[0] != ModuleTypeAnalyticsGoogleProvider { + t.Fatalf("ModuleTypes() = %v", got) + } +} + +func TestGoogleProviderRegistersAndUnregisters(t *testing.T) { + module, err := newGoogleProviderModule("google", map[string]any{ + "analytics_account": "accounts/123", + "tag_manager_account": "accounts/456", + }) + if err != nil { + t.Fatalf("newGoogleProviderModule: %v", err) + } + if err := module.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + provider, ok := getGoogleProvider("google") + if !ok { + t.Fatal("provider was not registered") + } + if provider.config.AnalyticsAccount != "accounts/123" { + t.Fatalf("analytics account = %q", provider.config.AnalyticsAccount) + } + if err := module.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + if _, ok := getGoogleProvider("google"); ok { + t.Fatal("provider remained registered after Stop") + } +} + +func TestGoogleProviderCredentialOptionsFromEnv(t *testing.T) { + t.Setenv("GOOGLE_CREDS_JSON", `{"type":"service_account"}`) + provider := googleProvider{config: GoogleProviderConfig{CredentialsJSONEnv: "GOOGLE_CREDS_JSON"}} + opts, explicit, err := provider.clientOptions() + if err != nil { + t.Fatalf("clientOptions: %v", err) + } + if !explicit { + t.Fatal("credentials JSON env should count as explicit credentials") + } + if len(opts) != 1 { + t.Fatalf("client options = %d, want 1", len(opts)) + } +} + +func TestGoogleProviderCredentialErrorRedactsValue(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.json") + provider := googleProvider{config: GoogleProviderConfig{CredentialsFile: path}} + _, _, err := provider.clientOptions() + if err == nil { + t.Fatal("expected missing credentials file error") + } + if strings.Contains(err.Error(), path) { + t.Fatalf("credential path leaked in error: %v", err) + } +} + +func TestGoogleAuditWriterAppendsJSONL(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.jsonl") + writer := googleAuditWriter{path: path} + if err := writer.Append(context.Background(), googleAuditEvent{ + Action: "ga4.ensure", + Account: "accounts/123", + Resource: "properties/1", + DryRun: true, + Operations: []string{"create_property"}, + }); err != nil { + t.Fatalf("Append: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + text := string(data) + for _, want := range []string{`"action":"ga4.ensure"`, `"account":"accounts/123"`, `"dry_run":true`, `"create_property"`} { + if !strings.Contains(text, want) { + t.Fatalf("audit missing %s: %s", want, text) + } + } +} diff --git a/internal/google_steps.go b/internal/google_steps.go new file mode 100644 index 0000000..8abe9f4 --- /dev/null +++ b/internal/google_steps.go @@ -0,0 +1,171 @@ +package internal + +import ( + "context" + "fmt" + + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +type analyticsGoogleGA4EnsureStep struct { + name string + config map[string]any +} + +type analyticsGoogleGTMEnsureStep struct { + name string + config map[string]any +} + +func newAnalyticsGoogleGA4EnsureStep(name string, config map[string]any) (*analyticsGoogleGA4EnsureStep, error) { + return &analyticsGoogleGA4EnsureStep{name: name, config: copyConfig(config)}, nil +} + +func newAnalyticsGoogleGTMEnsureStep(name string, config map[string]any) (*analyticsGoogleGTMEnsureStep, error) { + return &analyticsGoogleGTMEnsureStep{name: name, config: copyConfig(config)}, nil +} + +func (s *analyticsGoogleGA4EnsureStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, current map[string]any, _ map[string]any, config map[string]any) (*sdk.StepResult, error) { + merged := mergeConfig(s.config, config) + req := GA4EnsureRequest{ + Account: stringValue(merged, "account"), + PropertyName: stringValue(merged, "property_name"), + StreamName: stringValue(merged, "stream_name"), + DefaultURI: stringValue(merged, "default_uri"), + TimeZone: stringValue(merged, "time_zone"), + CurrencyCode: stringValue(merged, "currency_code"), + DryRun: boolConfig(merged, "dry_run", false), + } + if req.PropertyName == "" && current != nil { + req.PropertyName, _ = current["property_name"].(string) + } + provider, _ := providerFromConfig(merged) + if req.Account == "" { + req.Account = provider.config.AnalyticsAccount + } + client, audit, err := ga4ClientForProvider(ctx, provider, req.DryRun) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", StepTypeAnalyticsGoogleGA4Ensure, s.name, err) + } + result, err := EnsureGA4WebStream(ctx, client, req) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", StepTypeAnalyticsGoogleGA4Ensure, s.name, err) + } + _ = audit.Append(ctx, googleAuditEvent{Action: "ga4.ensure", Account: result.Account, Resource: result.Property, DryRun: result.DryRun, Operations: operationNameStrings(result.Operations)}) + return &sdk.StepResult{Output: map[string]any{ + "account": result.Account, + "property": result.Property, + "data_stream": result.DataStream, + "measurement_id": result.MeasurementID, + "dry_run": result.DryRun, + "operations": operationsOutput(result.Operations), + }}, nil +} + +func (s *analyticsGoogleGTMEnsureStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, _ map[string]any, _ map[string]any, config map[string]any) (*sdk.StepResult, error) { + merged := mergeConfig(s.config, config) + req := GTMEnsureRequest{ + Account: stringValue(merged, "account"), + ContainerName: stringValue(merged, "container_name"), + Domains: stringSliceConfig(merged, "domains"), + WorkspaceName: stringValue(merged, "workspace_name"), + MeasurementID: stringValue(merged, "measurement_id"), + DryRun: boolConfig(merged, "dry_run", false), + } + provider, _ := providerFromConfig(merged) + if req.Account == "" { + req.Account = provider.config.TagManagerAccount + } + client, audit, err := gtmClientForProvider(ctx, provider, req.DryRun) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", StepTypeAnalyticsGoogleGTMEnsure, s.name, err) + } + result, err := EnsureGTMWebContainer(ctx, client, req) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", StepTypeAnalyticsGoogleGTMEnsure, s.name, err) + } + _ = audit.Append(ctx, googleAuditEvent{Action: "gtm.ensure", Account: result.Account, Resource: result.ContainerPath, DryRun: result.DryRun, Operations: operationNameStrings(result.Operations)}) + return &sdk.StepResult{Output: map[string]any{ + "account": result.Account, + "container_path": result.ContainerPath, + "container_id": result.ContainerID, + "public_id": result.PublicID, + "workspace_path": result.WorkspacePath, + "gtag_config_path": result.GtagConfigPath, + "dry_run": result.DryRun, + "operations": operationsOutput(result.Operations), + }}, nil +} + +func ga4ClientForProvider(ctx context.Context, provider googleProvider, dryRun bool) (GA4AdminClient, googleAuditWriter, error) { + audit := googleAuditWriter{path: provider.config.AuditPath} + opts, explicit, err := provider.clientOptions() + if err != nil { + return nil, audit, err + } + if dryRun { + return nil, audit, nil + } + if !explicit { + return nil, audit, fmt.Errorf("google credentials are required for live GA4 ensure") + } + client, err := newGoogleGA4SDKClient(ctx, opts...) + return client, audit, err +} + +func gtmClientForProvider(ctx context.Context, provider googleProvider, dryRun bool) (TagManagerClient, googleAuditWriter, error) { + audit := googleAuditWriter{path: provider.config.AuditPath} + opts, explicit, err := provider.clientOptions() + if err != nil { + return nil, audit, err + } + if dryRun { + return nil, audit, nil + } + if !explicit { + return nil, audit, fmt.Errorf("google credentials are required for live GTM ensure") + } + client, err := newGoogleTagManagerSDKClient(ctx, opts...) + return client, audit, err +} + +func providerFromConfig(config map[string]any) (googleProvider, bool) { + name := stringValue(config, "provider") + if name == "" { + name = stringValue(config, "module") + } + if provider, ok := getGoogleProvider(name); ok { + return provider, true + } + return googleProvider{config: GoogleProviderConfig{ + CredentialsJSON: stringValue(config, "credentials_json"), + CredentialsJSONEnv: stringValue(config, "credentials_json_env"), + CredentialsFile: stringValue(config, "credentials_file"), + CredentialsFileEnv: stringValue(config, "credentials_file_env"), + AuditPath: stringValue(config, "audit_path"), + }}, false +} + +func copyConfig(config map[string]any) map[string]any { + out := make(map[string]any, len(config)) + for k, v := range config { + out[k] = v + } + return out +} + +func mergeConfig(base, override map[string]any) map[string]any { + out := copyConfig(base) + for k, v := range override { + out[k] = v + } + return out +} + +func operationsOutput(ops []Operation) []map[string]any { + out := make([]map[string]any, 0, len(ops)) + for _, op := range ops { + out = append(out, map[string]any{"name": op.Name, "status": op.Status}) + } + return out +} diff --git a/internal/google_test_helpers_test.go b/internal/google_test_helpers_test.go new file mode 100644 index 0000000..b05a0df --- /dev/null +++ b/internal/google_test_helpers_test.go @@ -0,0 +1,21 @@ +package internal + +func operationNames(ops []Operation) []string { + out := make([]string, 0, len(ops)) + for _, op := range ops { + out = append(out, op.Name) + } + return out +} + +func sameStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/plugin.go b/internal/plugin.go index 6b6261a..26d3b63 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -19,6 +19,16 @@ type analyticsPlugin struct{} // Workflow pipeline, covering handlers that render HTML at runtime. const StepTypeAnalyticsInjectHTML = "step.analytics_inject_html" +// ModuleTypeAnalyticsGoogleProvider registers Google credentials and default +// account wiring for Analytics Admin and Tag Manager provisioning. +const ModuleTypeAnalyticsGoogleProvider = "analytics.google_provider" + +// Google provisioning step types. +const ( + StepTypeAnalyticsGoogleGA4Ensure = "step.analytics_google_ga4_ensure" + StepTypeAnalyticsGoogleGTMEnsure = "step.analytics_google_gtm_ensure" +) + // NewPlugin returns a new plugin instance. main.go calls sdk.Serve(NewPlugin()). func NewPlugin() sdk.PluginProvider { return &analyticsPlugin{} @@ -37,7 +47,11 @@ func (p *analyticsPlugin) Manifest() sdk.PluginManifest { // StepTypes returns the runtime step types this plugin provides. func (p *analyticsPlugin) StepTypes() []string { - return []string{StepTypeAnalyticsInjectHTML} + return []string{ + StepTypeAnalyticsInjectHTML, + StepTypeAnalyticsGoogleGA4Ensure, + StepTypeAnalyticsGoogleGTMEnsure, + } } // CreateStep creates an analytics step instance. @@ -45,7 +59,26 @@ func (p *analyticsPlugin) CreateStep(typeName, name string, config map[string]an switch typeName { case StepTypeAnalyticsInjectHTML: return newAnalyticsInjectHTMLStep(name, config) + case StepTypeAnalyticsGoogleGA4Ensure: + return newAnalyticsGoogleGA4EnsureStep(name, config) + case StepTypeAnalyticsGoogleGTMEnsure: + return newAnalyticsGoogleGTMEnsureStep(name, config) default: return nil, fmt.Errorf("analytics plugin: unknown step type %q", typeName) } } + +// ModuleTypes returns provider module types surfaced by this plugin. +func (p *analyticsPlugin) ModuleTypes() []string { + return []string{ModuleTypeAnalyticsGoogleProvider} +} + +// CreateModule creates an analytics module instance. +func (p *analyticsPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.ModuleInstance, error) { + switch typeName { + case ModuleTypeAnalyticsGoogleProvider: + return newGoogleProviderModule(name, config) + default: + return nil, fmt.Errorf("analytics plugin: unknown module type %q", typeName) + } +} diff --git a/internal/step_test.go b/internal/step_test.go index 8db3947..413dfd9 100644 --- a/internal/step_test.go +++ b/internal/step_test.go @@ -14,7 +14,7 @@ func TestPluginExposesAnalyticsInjectStep(t *testing.T) { if !ok { t.Fatal("plugin does not expose step provider methods") } - if got := stepProvider.StepTypes(); len(got) != 1 || got[0] != StepTypeAnalyticsInjectHTML { + if got := stepProvider.StepTypes(); !sameStrings(got, []string{StepTypeAnalyticsInjectHTML, StepTypeAnalyticsGoogleGA4Ensure, StepTypeAnalyticsGoogleGTMEnsure}) { t.Fatalf("StepTypes() = %v", got) } } @@ -116,3 +116,50 @@ func TestAnalyticsInjectHTMLStepPerCallTagID(t *testing.T) { t.Errorf("per-call anonymize_ip not honoured; got %q", out) } } + +func TestAnalyticsGoogleGA4EnsureStepDryRun(t *testing.T) { + step, err := newAnalyticsGoogleGA4EnsureStep("ga4", map[string]any{ + "account": "accounts/123", + "property_name": "example.com", + "stream_name": "example.com", + "default_uri": "https://example.com", + "dry_run": true, + }) + if err != nil { + t.Fatalf("new step: %v", err) + } + res, err := step.Execute(context.Background(), nil, nil, nil, nil, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Output["dry_run"] != true { + t.Fatalf("dry_run output = %#v", res.Output) + } + if res.Output["measurement_id"] != "" { + t.Fatalf("measurement_id = %#v", res.Output["measurement_id"]) + } +} + +func TestAnalyticsGoogleGTMEnsureStepDryRun(t *testing.T) { + step, err := newAnalyticsGoogleGTMEnsureStep("gtm", map[string]any{ + "account": "accounts/456", + "container_name": "example.com", + "domains": []any{"example.com"}, + "workspace_name": "workflow", + "measurement_id": "G-ABC123", + "dry_run": true, + }) + if err != nil { + t.Fatalf("new step: %v", err) + } + res, err := step.Execute(context.Background(), nil, nil, nil, nil, nil) + if err != nil { + t.Fatalf("execute: %v", err) + } + if res.Output["dry_run"] != true { + t.Fatalf("dry_run output = %#v", res.Output) + } + if res.Output["public_id"] != "" { + t.Fatalf("public_id = %#v", res.Output["public_id"]) + } +} From e7ec91ff1fc77a578db7a06eced9d80e817090d6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:03:15 -0400 Subject: [PATCH 06/10] docs: make google analytics yaml primary --- README.md | 80 ++++++++++- .../gocodealone-multisite-google-analytics.md | 127 ++++++++++++++++++ ...26-google-analytics-provisioning-design.md | 4 + examples/minimal/config.yaml | 51 ++++++- 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 docs/gocodealone-multisite-google-analytics.md diff --git a/README.md b/README.md index 1dc6768..11d6047 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > ✅ **Verified** — used in production at **buymywishlist**. This plugin has been validated end-to-end in a merged main-branch wfctl.yaml of an active GoCodeAlone project. -`workflow-plugin-analytics` injects analytics and tag-manager snippets into rendered HTML assets from `wfctl`. +`workflow-plugin-analytics` injects analytics and tag-manager snippets into rendered HTML assets from `wfctl`. It can also provision Google Analytics 4 web streams and Google Tag Manager web containers programmatically. The first supported provider is Google Analytics through the Google tag (`gtag.js`). The plugin also includes Google Tag Manager snippet injection so apps can switch to a container-managed setup later. @@ -53,6 +53,83 @@ circuits to `skipped: true, reason: "empty tag id"`. When the tenant sets `anonymize_ip: true`, the GA4 `config(...)` call emits `{'anonymize_ip': true}`. +## Google provisioning in wfctl YAML + +The primary provisioning surface is Workflow/wfctl YAML. Put the desired GA/GTM resources in the same config that owns deploy and secret wiring, usually `deploy.yaml` or `infra.yaml`. + +```yaml +secretStores: + github-actions: + provider: github + config: + repo: GoCodeAlone/example-site + token_env: RELEASES_TOKEN + +secrets: + defaultStore: github-actions + entries: + - name: GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON + description: Service-account JSON with GA Admin + GTM access. + +modules: + - name: google-analytics + type: analytics.google_provider + config: + credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + analytics_account: accounts/123456789 + tag_manager_account: accounts/987654321 + +pipelines: + apply: + steps: + - name: ensure_ga4 + type: step.analytics_google_ga4_ensure + config: + provider: google-analytics + property_name: example.com + stream_name: example.com + default_uri: https://example.com + dry_run: true + + - name: ensure_gtm + type: step.analytics_google_gtm_ensure + config: + provider: google-analytics + container_name: example.com + domains: [example.com, www.example.com] + workspace_name: workflow + measurement_id: ${ensure_ga4.measurement_id} + dry_run: true +``` + +`wfctl infra apply -c deploy.yaml` can run the `pipelines.apply` path for config-driven resources. Keep `dry_run: true` until Google API access and account permissions are in place. + +The CLI is still useful for smoke checks and one-off operator probes. Dry-run GA4 provisioning: + +```sh +wfctl analytics google ga4 ensure \ + --account accounts/123456789 \ + --property-name example.com \ + --stream-name example.com \ + --default-uri https://example.com \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run +``` + +Dry-run GTM provisioning: + +```sh +wfctl analytics google gtm ensure \ + --account accounts/987654321 \ + --container-name example.com \ + --domain example.com \ + --measurement-id G-XXXXXXXXXX \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run +``` + +Live apply requires Google API access and credentials. Audit events for state-mutating and dry-run ensure commands are appended to `${XDG_STATE_HOME:-$HOME/.local/state}/wfctl/plugins/analytics/google-audit.jsonl` unless `audit_path` overrides it. + ## Providers - `google-analytics`: injects the Google tag into ``. @@ -64,6 +141,7 @@ true}`. - Managed blocks are replaced idempotently. - Existing unmanaged snippets for the same provider ID are detected and left untouched to avoid double injection. - The command can process one file with `--html` or all `.html` files below a directory with `--dir`. +- Google credentials are read from env/file/ADC and are never written to audit logs. ## References diff --git a/docs/gocodealone-multisite-google-analytics.md b/docs/gocodealone-multisite-google-analytics.md new file mode 100644 index 0000000..43a04ab --- /dev/null +++ b/docs/gocodealone-multisite-google-analytics.md @@ -0,0 +1,127 @@ +# gocodealone-multisite Google Analytics Provisioning + +This runbook wires `gocodealone-multisite` to programmatically provision GA4 web streams and optional GTM web containers through `workflow-plugin-analytics`. + +The source of truth should be `deploy.yaml` / `deploy.prereq.yaml`, not ad hoc CLI state. CLI commands below are smoke probes for the same operations expressed in YAML. + +## Prerequisites + +- Enable Google Analytics Admin API and Tag Manager API for the credential project. +- Create or choose a service account / ADC principal. +- Grant it access to the target Google Analytics account(s) and Google Tag Manager account(s). +- Store credentials as a deploy secret, for example `GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON`. +- Choose umbrella accounts: + - GA: `accounts/` + - GTM: `accounts/` + +Stop here until the operator has provisioned API access. + +## wfctl plugin pin + +Add the analytics plugin to `gocodealone-multisite/wfctl.yaml` after release: + +```yaml +plugins: + - name: workflow-plugin-analytics + version: vNEXT + source: github.com/GoCodeAlone/workflow-plugin-analytics +``` + +## Secret entries + +Add this entry to `deploy.prereq.yaml` and `deploy.yaml` secret lists: + +```yaml +- name: GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON + description: Service-account JSON with Analytics Admin and Tag Manager access. +``` + +## deploy.yaml desired state + +Add the provider module and `pipelines.apply` steps to `deploy.yaml` so analytics resources are reconciled with the rest of the deploy intent: + +```yaml +modules: + - name: google-analytics + type: analytics.google_provider + config: + credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + analytics_account: accounts/123456789 + tag_manager_account: accounts/987654321 + +pipelines: + apply: + steps: + - name: ensure_gocodealone_ga4 + type: step.analytics_google_ga4_ensure + config: + provider: google-analytics + property_name: gocodealone.tech + stream_name: gocodealone.tech + default_uri: https://gocodealone.tech + dry_run: true + + - name: ensure_gocodealone_gtm + type: step.analytics_google_gtm_ensure + config: + provider: google-analytics + container_name: gocodealone.tech + domains: [gocodealone.tech, www.gocodealone.tech] + workspace_name: workflow + measurement_id: ${ensure_gocodealone_ga4.measurement_id} + dry_run: true +``` + +Keep `dry_run: true` until the operator grants API access. After reviewing dry-run output, switch the specific site step to `dry_run: false` and run: + +```sh +wfctl infra apply -c deploy.yaml --wait +``` + +## CLI smoke probe + +Run the equivalent GA4 dry-run directly when validating credentials or debugging: + +```sh +wfctl analytics google ga4 ensure \ + --account accounts/123456789 \ + --property-name gocodealone.tech \ + --stream-name gocodealone.tech \ + --default-uri https://gocodealone.tech \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run +``` + +Optional GTM dry-run: + +```sh +wfctl analytics google gtm ensure \ + --account accounts/987654321 \ + --container-name gocodealone.tech \ + --domain gocodealone.tech \ + --domain www.gocodealone.tech \ + --measurement-id G-XXXXXXXXXX \ + --credentials-json-env GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON \ + --dry-run +``` + +The output is JSON and can be copied into deployment logs or a future Workflow pipeline step. Live apply removes `--dry-run`, but only after the dry-run output is reviewed. + +## Content repo update + +After live GA4 apply returns a measurement ID, set it in the content repo `multisite.yaml`: + +```yaml +analytics: + google: + measurement_id: G-XXXXXXXXXX + anonymize_ip: true +``` + +`gocodealone-multisite` then passes that ID into `step.analytics_inject_html`; tenants without an ID keep analytics disabled. + +## Rollback + +- Remove or blank `analytics.google.measurement_id` in the content repo and redeploy content. +- Run `wfctl analytics inject` with an empty tag ID when mutating static assets to remove managed snippets. +- Do not delete GA/GTM resources automatically; deletion can destroy useful history. diff --git a/docs/plans/2026-05-26-google-analytics-provisioning-design.md b/docs/plans/2026-05-26-google-analytics-provisioning-design.md index d1e1026..820db21 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning-design.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning-design.md @@ -187,3 +187,7 @@ Stop before live Google API mutation. Required operator inputs: - Credential secret value or ADC setup. - GA account IDs and GTM account IDs for each umbrella. - Confirmation that the principal has create/list permissions in those accounts. + +## Backport: YAML Primary Surface + +2026-05-26: User clarified GA/GTM should be managed from `wfctl`/IaC/infra YAML together with secret management. The design already had Workflow steps, but examples overemphasized CLI. Corrected behavior: `deploy.yaml` or `infra.yaml` owns `analytics.google_provider` plus `pipelines.apply` ensure steps; CLI remains a smoke/operator path. Manifest scope unchanged because Task 5 and Task 6 already cover steps and consumer deployment guidance. diff --git a/examples/minimal/config.yaml b/examples/minimal/config.yaml index 0028c3c..e0673ef 100644 --- a/examples/minimal/config.yaml +++ b/examples/minimal/config.yaml @@ -1,2 +1,51 @@ -# Placeholder minimal config for workflow-plugin-analytics. version: 1 + +secretStores: + github-actions: + provider: github + config: + repo: GoCodeAlone/example-site + token_env: RELEASES_TOKEN + +secrets: + defaultStore: github-actions + entries: + - name: GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON + description: Service-account JSON with GA Admin + GTM access. + +modules: + - name: google-analytics + type: analytics.google_provider + config: + credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + analytics_account: accounts/123456789 + tag_manager_account: accounts/987654321 + +pipelines: + apply: + steps: + - name: ensure_ga4 + type: step.analytics_google_ga4_ensure + config: + provider: google-analytics + property_name: example.com + stream_name: example.com + default_uri: https://example.com + dry_run: true + + - name: ensure_gtm + type: step.analytics_google_gtm_ensure + config: + provider: google-analytics + container_name: example.com + domains: [example.com, www.example.com] + workspace_name: workflow + measurement_id: ${ensure_ga4.measurement_id} + dry_run: true + + - name: inject_analytics + type: step.analytics_inject_html + config: + provider: google-analytics + tag_id: ${ensure_ga4.measurement_id} + html_field: html From d4a44b7db3be37baed2b9b2d4124bd43338e7185 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:03:21 -0400 Subject: [PATCH 07/10] chore: declare google analytics provisioning contracts --- plugin.contracts.json | 20 ++++++++++++++++++++ plugin.json | 14 +++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/plugin.contracts.json b/plugin.contracts.json index 6027549..1b59efa 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -7,6 +7,26 @@ "mode": "strict", "input": "workflow.plugins.analytics.AnalyticsInjectHTMLInput", "output": "workflow.plugins.analytics.AnalyticsInjectHTMLOutput" + }, + { + "kind": "module", + "type": "analytics.google_provider", + "mode": "strict", + "config": "workflow.plugins.analytics.GoogleProviderConfig" + }, + { + "kind": "step", + "type": "step.analytics_google_ga4_ensure", + "mode": "strict", + "input": "workflow.plugins.analytics.GoogleGA4EnsureInput", + "output": "workflow.plugins.analytics.GoogleGA4EnsureOutput" + }, + { + "kind": "step", + "type": "step.analytics_google_gtm_ensure", + "mode": "strict", + "input": "workflow.plugins.analytics.GoogleGTMEnsureInput", + "output": "workflow.plugins.analytics.GoogleGTMEnsureOutput" } ] } diff --git a/plugin.json b/plugin.json index 685062e..cce6ec0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "workflow-plugin-analytics", "version": "0.1.2", - "description": "Analytics and tag-manager injection for rendered HTML assets", + "description": "Analytics and tag-manager injection plus Google provisioning for Workflow apps", "author": "GoCodeAlone", "license": "MIT", "type": "external", @@ -18,9 +18,13 @@ "homepage": "https://github.com/GoCodeAlone/workflow-plugin-analytics", "repository": "https://github.com/GoCodeAlone/workflow-plugin-analytics", "capabilities": { - "moduleTypes": [], + "moduleTypes": [ + "analytics.google_provider" + ], "stepTypes": [ - "step.analytics_inject_html" + "step.analytics_inject_html", + "step.analytics_google_ga4_ensure", + "step.analytics_google_gtm_ensure" ], "triggerTypes": [], "cliCommands": [ @@ -32,6 +36,10 @@ { "name": "inject", "description": "Inject provider snippet into HTML files" + }, + { + "name": "google", + "description": "Ensure Google Analytics and Google Tag Manager resources" } ] } From 947753188b9fc8f94356c33c9d3f4556d2a7f4c5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:04:33 -0400 Subject: [PATCH 08/10] docs: fix provisioning cli smoke command --- docs/plans/2026-05-26-google-analytics-provisioning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-05-26-google-analytics-provisioning.md b/docs/plans/2026-05-26-google-analytics-provisioning.md index ef114e7..a8c5aef 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning.md @@ -119,6 +119,6 @@ 2. Add an example Workflow config showing provider module, GA4 ensure step, GTM ensure step, and existing HTML injection step consuming returned IDs. 3. Run `GOWORK=off go test ./...`; expected: PASS. 4. Run `GOWORK=off go build ./...`; expected: PASS. -5. Run CLI smoke: `GOWORK=off go run ./cmd/workflow-plugin-analytics analytics google ga4 ensure --account accounts/123 --property-name example.com --stream-name example.com --default-uri https://example.com --dry-run`; expected JSON includes `"dry_run":true` and `"measurement_id":""`. +5. Run CLI smoke: `GOWORK=off go run ./cmd/workflow-plugin-analytics --wfctl-cli analytics google ga4 ensure --account accounts/123 --property-name example.com --stream-name example.com --default-uri https://example.com --dry-run`; expected JSON includes `"dry_run":true` and `"measurement_id":""`. 6. Confirm live apply is still blocked by running the same command without `--dry-run` and no credentials; expected non-zero exit and an error that names missing Google credentials without printing any credential value. 7. Rollback: revert docs/example commit; no live resources touched. From 003f6a698c3b44395ecc69d49287a5163124a6ed Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:05:45 -0400 Subject: [PATCH 09/10] fix: require explicit adc opt-in --- README.md | 3 +++ docs/gocodealone-multisite-google-analytics.md | 3 +++ ...6-05-26-google-analytics-provisioning-design.md | 3 ++- examples/minimal/config.yaml | 2 ++ internal/cli.go | 4 ++++ internal/google_provider.go | 5 +++++ internal/google_provider_test.go | 14 ++++++++++++++ internal/google_steps.go | 1 + 8 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 11d6047..1b42be3 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ modules: type: analytics.google_provider config: credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + # Set allow_adc: true only for runners where Application Default + # Credentials are the intended deploy identity. + allow_adc: false analytics_account: accounts/123456789 tag_manager_account: accounts/987654321 diff --git a/docs/gocodealone-multisite-google-analytics.md b/docs/gocodealone-multisite-google-analytics.md index 43a04ab..72049d6 100644 --- a/docs/gocodealone-multisite-google-analytics.md +++ b/docs/gocodealone-multisite-google-analytics.md @@ -46,6 +46,9 @@ modules: type: analytics.google_provider config: credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + # Keep false for GitHub-secret driven deploys; set true only when + # the runner's ADC identity is intentionally the deploy principal. + allow_adc: false analytics_account: accounts/123456789 tag_manager_account: accounts/987654321 diff --git a/docs/plans/2026-05-26-google-analytics-provisioning-design.md b/docs/plans/2026-05-26-google-analytics-provisioning-design.md index 820db21..d34a13a 100644 --- a/docs/plans/2026-05-26-google-analytics-provisioning-design.md +++ b/docs/plans/2026-05-26-google-analytics-provisioning-design.md @@ -64,9 +64,10 @@ modules: analytics_account: accounts/123456789 tag_manager_account: accounts/987654321 audit_path: "" + allow_adc: false ``` -The module registers a provider by name. Empty credentials use Google Application Default Credentials. Inline JSON and file paths are never logged. `analytics_account` and `tag_manager_account` can be overridden per step/CLI call to support umbrella accounts. +The module registers a provider by name. Inline JSON and file paths are never logged. `allow_adc: true` explicitly opts into Google Application Default Credentials for environments where ADC is the intended deploy identity. `analytics_account` and `tag_manager_account` can be overridden per step/CLI call to support umbrella accounts. ### GA4 Reconcile diff --git a/examples/minimal/config.yaml b/examples/minimal/config.yaml index e0673ef..b6312dd 100644 --- a/examples/minimal/config.yaml +++ b/examples/minimal/config.yaml @@ -18,6 +18,8 @@ modules: type: analytics.google_provider config: credentials_json: ${GOOGLE_ANALYTICS_ADMIN_CREDENTIALS_JSON} + # Set allow_adc: true only for environments where ADC is intentional. + allow_adc: false analytics_account: accounts/123456789 tag_manager_account: accounts/987654321 diff --git a/internal/cli.go b/internal/cli.go index 419f260..1ec696e 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -80,12 +80,14 @@ func (c *CLIProvider) runGoogleGA4Ensure(args []string) int { dryRun := fs.Bool("dry-run", false, "Plan changes without calling Google APIs") credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") + allowADC := fs.Bool("allow-adc", false, "Use Application Default Credentials for live apply") if err := fs.Parse(args); err != nil { return 2 } provider := googleProvider{config: GoogleProviderConfig{ CredentialsJSONEnv: *credentialsJSONEnv, CredentialsFileEnv: *credentialsFileEnv, + AllowADC: *allowADC, }} client, audit, err := ga4ClientForProvider(context.Background(), provider, *dryRun) if err != nil { @@ -119,6 +121,7 @@ func (c *CLIProvider) runGoogleGTMEnsure(args []string) int { dryRun := fs.Bool("dry-run", false, "Plan changes without calling Google APIs") credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") + allowADC := fs.Bool("allow-adc", false, "Use Application Default Credentials for live apply") var domains repeatedFlag fs.Var(&domains, "domain", "Domain associated with the web container; repeatable") if err := fs.Parse(args); err != nil { @@ -127,6 +130,7 @@ func (c *CLIProvider) runGoogleGTMEnsure(args []string) int { provider := googleProvider{config: GoogleProviderConfig{ CredentialsJSONEnv: *credentialsJSONEnv, CredentialsFileEnv: *credentialsFileEnv, + AllowADC: *allowADC, }} client, audit, err := gtmClientForProvider(context.Background(), provider, *dryRun) if err != nil { diff --git a/internal/google_provider.go b/internal/google_provider.go index 4b87ce9..627fbaf 100644 --- a/internal/google_provider.go +++ b/internal/google_provider.go @@ -17,6 +17,7 @@ type GoogleProviderConfig struct { AnalyticsAccount string TagManagerAccount string AuditPath string + AllowADC bool } type googleProvider struct { @@ -42,6 +43,7 @@ func newGoogleProviderModule(name string, config map[string]any) (*googleProvide AnalyticsAccount: stringValue(config, "analytics_account"), TagManagerAccount: stringValue(config, "tag_manager_account"), AuditPath: stringValue(config, "audit_path"), + AllowADC: boolConfig(config, "allow_adc", false), } if cfg.AuditPath == "" { cfg.AuditPath = defaultGoogleAuditPath() @@ -102,6 +104,9 @@ func (p googleProvider) clientOptions() ([]option.ClientOption, bool, error) { } return []option.ClientOption{option.WithCredentialsFile(path)}, true, nil } + if p.config.AllowADC { + return nil, true, nil + } return nil, false, nil } diff --git a/internal/google_provider_test.go b/internal/google_provider_test.go index e3f8880..042505c 100644 --- a/internal/google_provider_test.go +++ b/internal/google_provider_test.go @@ -62,6 +62,20 @@ func TestGoogleProviderCredentialOptionsFromEnv(t *testing.T) { } } +func TestGoogleProviderAllowsADCWhenExplicit(t *testing.T) { + provider := googleProvider{config: GoogleProviderConfig{AllowADC: true}} + opts, explicit, err := provider.clientOptions() + if err != nil { + t.Fatalf("clientOptions: %v", err) + } + if !explicit { + t.Fatal("allow_adc should authorize live SDK construction") + } + if len(opts) != 0 { + t.Fatalf("ADC should not add explicit options, got %d", len(opts)) + } +} + func TestGoogleProviderCredentialErrorRedactsValue(t *testing.T) { path := filepath.Join(t.TempDir(), "missing.json") provider := googleProvider{config: GoogleProviderConfig{CredentialsFile: path}} diff --git a/internal/google_steps.go b/internal/google_steps.go index 8abe9f4..b1da0ac 100644 --- a/internal/google_steps.go +++ b/internal/google_steps.go @@ -143,6 +143,7 @@ func providerFromConfig(config map[string]any) (googleProvider, bool) { CredentialsFile: stringValue(config, "credentials_file"), CredentialsFileEnv: stringValue(config, "credentials_file_env"), AuditPath: stringValue(config, "audit_path"), + AllowADC: boolConfig(config, "allow_adc", false), }}, false } From 1260aa940d11403097125b6ea7405828b5da60a8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 02:07:17 -0400 Subject: [PATCH 10/10] fix: audit google cli ensure runs --- internal/cli.go | 4 ++++ internal/cli_test.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/cli.go b/internal/cli.go index 1ec696e..e298cfc 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -81,6 +81,7 @@ func (c *CLIProvider) runGoogleGA4Ensure(args []string) int { credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") allowADC := fs.Bool("allow-adc", false, "Use Application Default Credentials for live apply") + auditPath := fs.String("audit-path", defaultGoogleAuditPath(), "JSONL audit log path") if err := fs.Parse(args); err != nil { return 2 } @@ -88,6 +89,7 @@ func (c *CLIProvider) runGoogleGA4Ensure(args []string) int { CredentialsJSONEnv: *credentialsJSONEnv, CredentialsFileEnv: *credentialsFileEnv, AllowADC: *allowADC, + AuditPath: *auditPath, }} client, audit, err := ga4ClientForProvider(context.Background(), provider, *dryRun) if err != nil { @@ -122,6 +124,7 @@ func (c *CLIProvider) runGoogleGTMEnsure(args []string) int { credentialsJSONEnv := fs.String("credentials-json-env", "", "Environment variable containing service-account JSON") credentialsFileEnv := fs.String("credentials-file-env", "", "Environment variable containing service-account JSON file path") allowADC := fs.Bool("allow-adc", false, "Use Application Default Credentials for live apply") + auditPath := fs.String("audit-path", defaultGoogleAuditPath(), "JSONL audit log path") var domains repeatedFlag fs.Var(&domains, "domain", "Domain associated with the web container; repeatable") if err := fs.Parse(args); err != nil { @@ -131,6 +134,7 @@ func (c *CLIProvider) runGoogleGTMEnsure(args []string) int { CredentialsJSONEnv: *credentialsJSONEnv, CredentialsFileEnv: *credentialsFileEnv, AllowADC: *allowADC, + AuditPath: *auditPath, }} client, audit, err := gtmClientForProvider(context.Background(), provider, *dryRun) if err != nil { diff --git a/internal/cli_test.go b/internal/cli_test.go index 437de3e..751b758 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -63,6 +63,8 @@ func TestCLIInjectEmptyEnvNoop(t *testing.T) { } func TestCLIAnalyticsGoogleGA4EnsureDryRun(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) var stdout, stderr bytes.Buffer code := newCLIProvider(&stdout, &stderr).RunCLI([]string{ "analytics", "google", "ga4", "ensure", @@ -85,9 +87,18 @@ func TestCLIAnalyticsGoogleGA4EnsureDryRun(t *testing.T) { if got := operationNames(result.Operations); !sameStrings(got, []string{"create_property", "create_web_data_stream"}) { t.Fatalf("operations = %v", got) } + auditPath := filepath.Join(stateHome, "wfctl", "plugins", "analytics", "google-audit.jsonl") + data, err := os.ReadFile(auditPath) + if err != nil { + t.Fatalf("audit file: %v", err) + } + if !strings.Contains(string(data), `"action":"ga4.ensure"`) { + t.Fatalf("audit missing ga4 action: %s", string(data)) + } } func TestCLIAnalyticsGoogleGTMEnsureDryRun(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) var stdout, stderr bytes.Buffer code := newCLIProvider(&stdout, &stderr).RunCLI([]string{ "analytics", "google", "gtm", "ensure",