diff --git a/README.md b/README.md index 811959e..1dc6768 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,27 @@ Apps that render HTML directly from Workflow handlers can run the same injector The step reads HTML from `config.html` or `current[html_field]`, writes the mutated document to output key `html`, and returns `injected`, `skipped`, `reason`, and `provider` metadata. +## Per-tenant (multi-app) usage + +The step accepts `tag_id` and `anonymize_ip` at execute time as well as at +build time. A multi-tenant host (e.g. `gocodealone-multisite`) resolves the +tenant first, then passes per-tenant values via the step's runtime config: + +```yaml +- name: inject_analytics + type: step.analytics_inject_html + config: + provider: google-analytics + # tag_id + anonymize_ip omitted here — resolved per request from + # the tenant's multisite.yaml and merged into config at dispatch. + html_field: html +``` + +If the tenant has no `analytics.google.measurement_id`, the step short- +circuits to `skipped: true, reason: "empty tag id"`. When the tenant sets +`anonymize_ip: true`, the GA4 `config(...)` call emits `{'anonymize_ip': +true}`. + ## Providers - `google-analytics`: injects the Google tag into `
`. diff --git a/internal/cli.go b/internal/cli.go index fc7ea75..5723d88 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -56,6 +56,7 @@ func (c *CLIProvider) runInject(args []string) int { htmlPath := fs.String("html", "", "HTML file to mutate") dir := fs.String("dir", "", "Directory to recursively process for .html files") dryRun := fs.Bool("dry-run", false, "Report changes without writing files") + anonymizeIP := fs.Bool("anonymize-ip", false, "Emit anonymize_ip:true in the GA4 config (privacy mode)") if err := fs.Parse(args); err != nil { return 2 } @@ -65,11 +66,12 @@ func (c *CLIProvider) runInject(args []string) int { id = os.Getenv(*tagIDEnv) } summary, err := Inject(InjectOptions{ - Provider: *provider, - TagID: id, - HTMLPath: *htmlPath, - Dir: *dir, - DryRun: *dryRun, + Provider: *provider, + TagID: id, + HTMLPath: *htmlPath, + Dir: *dir, + DryRun: *dryRun, + AnonymizeIP: *anonymizeIP, }) if err != nil { fmt.Fprintf(c.stderr, "analytics inject: %v\n", err) diff --git a/internal/inject.go b/internal/inject.go index 25c80cc..15383d6 100644 --- a/internal/inject.go +++ b/internal/inject.go @@ -21,11 +21,12 @@ var safeTagIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // InjectOptions controls HTML tag injection. type InjectOptions struct { - Provider string - TagID string - HTMLPath string - Dir string - DryRun bool + Provider string + TagID string + HTMLPath string + Dir string + DryRun bool + AnonymizeIP bool } // InjectSummary reports what an injection run did. @@ -67,7 +68,7 @@ func Inject(opts InjectOptions) (InjectSummary, error) { summary.Reason = "empty tag id" } for _, path := range paths { - changed, err := injectFile(path, provider, tagID, opts.DryRun) + changed, err := injectFile(path, provider, tagID, opts.DryRun, opts.AnonymizeIP) if err != nil { return summary, err } @@ -116,13 +117,13 @@ func htmlPaths(htmlPath, dir string) ([]string, error) { return paths, nil } -func injectFile(path, provider, tagID string, dryRun bool) (bool, error) { +func injectFile(path, provider, tagID string, dryRun, anonymizeIP bool) (bool, error) { data, err := os.ReadFile(path) if err != nil { return false, fmt.Errorf("read %s: %w", path, err) } original := string(data) - next, err := injectHTML(original, provider, tagID) + next, err := injectHTML(original, provider, tagID, anonymizeIP) if err != nil { return false, fmt.Errorf("%s: %w", path, err) } @@ -138,7 +139,7 @@ func injectFile(path, provider, tagID string, dryRun bool) (bool, error) { return true, nil } -func injectHTML(input, provider, tagID string) (string, error) { +func injectHTML(input, provider, tagID string, anonymizeIP bool) (string, error) { htmlDoc := removeManagedBlocks(input, provider) if tagID == "" { return htmlDoc, nil @@ -148,7 +149,7 @@ func injectHTML(input, provider, tagID string) (string, error) { } switch provider { case ProviderGoogleAnalytics: - return injectBeforeClosingTag(htmlDoc, "", googleAnalyticsBlock(tagID)) + return injectBeforeClosingTag(htmlDoc, "", googleAnalyticsBlock(tagID, anonymizeIP)) case ProviderGoogleTagManager: withHead, err := injectBeforeClosingTag(htmlDoc, "", googleTagManagerHeadBlock(tagID)) if err != nil { @@ -230,19 +231,23 @@ func blockEnd(provider, slot string) string { return fmt.Sprintf("", provider, slot) } -func googleAnalyticsBlock(tagID string) string { +func googleAnalyticsBlock(tagID string, anonymizeIP bool) string { idJS := escapeJSString(tagID) idURL := url.QueryEscape(tagID) + configCall := fmt.Sprintf("gtag('config', '%s');", idJS) + if anonymizeIP { + configCall = fmt.Sprintf("gtag('config', '%s', {'anonymize_ip': true});", idJS) + } return fmt.Sprintf(`%s %s -`, blockStart(ProviderGoogleAnalytics, "head"), idURL, idJS, blockEnd(ProviderGoogleAnalytics, "head")) +`, blockStart(ProviderGoogleAnalytics, "head"), idURL, configCall, blockEnd(ProviderGoogleAnalytics, "head")) } func googleTagManagerHeadBlock(tagID string) string { diff --git a/internal/inject_test.go b/internal/inject_test.go index 8b53459..e55bf58 100644 --- a/internal/inject_test.go +++ b/internal/inject_test.go @@ -19,7 +19,7 @@ const sampleHTML = ` ` func TestInjectHTML_GoogleAnalytics(t *testing.T) { - got, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-ABC123") + got, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-ABC123", false) if err != nil { t.Fatalf("injectHTML: %v", err) } @@ -31,11 +31,11 @@ func TestInjectHTML_GoogleAnalytics(t *testing.T) { } func TestInjectHTML_GoogleAnalyticsIdempotent(t *testing.T) { - first, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-FIRST") + first, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-FIRST", false) if err != nil { t.Fatalf("first inject: %v", err) } - second, err := injectHTML(first, ProviderGoogleAnalytics, "G-SECOND") + second, err := injectHTML(first, ProviderGoogleAnalytics, "G-SECOND", false) if err != nil { t.Fatalf("second inject: %v", err) } @@ -53,7 +53,7 @@ func TestInjectHTML_GoogleAnalyticsSkipsUnmanagedSameTag(t *testing.T) { `, 1) - got, err := injectHTML(existing, ProviderGoogleAnalytics, "G-EXISTING") + got, err := injectHTML(existing, ProviderGoogleAnalytics, "G-EXISTING", false) if err != nil { t.Fatalf("injectHTML: %v", err) } @@ -66,11 +66,11 @@ func TestInjectHTML_GoogleAnalyticsSkipsUnmanagedSameTag(t *testing.T) { } func TestInjectHTML_EmptyTagIDRemovesManagedBlock(t *testing.T) { - withTag, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-ABC123") + withTag, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-ABC123", false) if err != nil { t.Fatalf("inject: %v", err) } - withoutTag, err := injectHTML(withTag, ProviderGoogleAnalytics, "") + withoutTag, err := injectHTML(withTag, ProviderGoogleAnalytics, "", false) if err != nil { t.Fatalf("remove: %v", err) } @@ -80,7 +80,7 @@ func TestInjectHTML_EmptyTagIDRemovesManagedBlock(t *testing.T) { } func TestInjectHTML_GoogleTagManager(t *testing.T) { - got, err := injectHTML(sampleHTML, ProviderGoogleTagManager, "GTM-ABC123") + got, err := injectHTML(sampleHTML, ProviderGoogleTagManager, "GTM-ABC123", false) if err != nil { t.Fatalf("injectHTML: %v", err) } @@ -97,7 +97,7 @@ func TestInjectHTML_GoogleTagManagerSkipsUnmanagedSameTag(t *testing.T) { existing := strings.Replace(sampleHTML, "", ` `, 1) - got, err := injectHTML(existing, ProviderGoogleTagManager, "GTM-EXISTING") + got, err := injectHTML(existing, ProviderGoogleTagManager, "GTM-EXISTING", false) if err != nil { t.Fatalf("injectHTML: %v", err) } @@ -156,3 +156,23 @@ func assertContains(t *testing.T, got, want string) { t.Fatalf("missing %q in:\n%s", want, got) } } + +func TestInjectHTMLAnonymizeIPInjected(t *testing.T) { + got, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-ANON123", true) + if err != nil { + t.Fatalf("injectHTML: %v", err) + } + if !strings.Contains(got, "'anonymize_ip': true") { + t.Errorf("expected anonymize_ip flag in snippet; got %q", got) + } +} + +func TestInjectHTMLAnonymizeIPDefaultOff(t *testing.T) { + got, err := injectHTML(sampleHTML, ProviderGoogleAnalytics, "G-PLAIN123", false) + if err != nil { + t.Fatalf("injectHTML: %v", err) + } + if strings.Contains(got, "anonymize_ip") { + t.Errorf("anonymize_ip must NOT appear when flag is false; got %q", got) + } +} diff --git a/internal/step.go b/internal/step.go index 92d59d8..fa7eb37 100644 --- a/internal/step.go +++ b/internal/step.go @@ -10,11 +10,12 @@ import ( ) type analyticsInjectHTMLStep struct { - name string - provider string - tagID string - tagIDEnv string - htmlKey string + name string + provider string + tagID string + tagIDEnv string + htmlKey string + anonymizeIP bool } func newAnalyticsInjectHTMLStep(name string, config map[string]any) (*analyticsInjectHTMLStep, error) { @@ -38,6 +39,9 @@ func newAnalyticsInjectHTMLStep(name string, config map[string]any) (*analyticsI if v, ok := stringConfig(config, "html_field"); ok { step.htmlKey = v } + if v, ok := config["anonymize_ip"].(bool); ok { + step.anonymizeIP = v + } return step, nil } @@ -68,6 +72,10 @@ func (s *analyticsInjectHTMLStep) Execute( if strings.TrimSpace(tagID) == "" && tagIDEnv != "" { tagID = os.Getenv(tagIDEnv) } + anonymizeIP := s.anonymizeIP + if v, ok := config["anonymize_ip"].(bool); ok { + anonymizeIP = v + } rawHTML, ok := stringConfig(config, "html") if !ok && current != nil { @@ -77,7 +85,7 @@ func (s *analyticsInjectHTMLStep) Execute( return nil, fmt.Errorf("%s %q: html is required in config.html or current[%q]", StepTypeAnalyticsInjectHTML, s.name, htmlKey) } - next, err := injectHTML(rawHTML, provider, strings.TrimSpace(tagID)) + next, err := injectHTML(rawHTML, provider, strings.TrimSpace(tagID), anonymizeIP) if err != nil { return nil, fmt.Errorf("%s %q: %w", StepTypeAnalyticsInjectHTML, s.name, err) } diff --git a/internal/step_test.go b/internal/step_test.go index 6c0e793..8db3947 100644 --- a/internal/step_test.go +++ b/internal/step_test.go @@ -66,3 +66,53 @@ func TestAnalyticsInjectHTMLStepEmptyEnvNoops(t *testing.T) { t.Fatalf("unexpected flags: %#v", result.Output) } } + +func TestAnalyticsInjectHTMLStepAnonymizeIP(t *testing.T) { + step, err := newAnalyticsInjectHTMLStep("inj", map[string]any{ + "tag_id": "G-TENANTA", + "anonymize_ip": true, + }) + if err != nil { + t.Fatalf("new step: %v", err) + } + res, err := step.Execute( + context.Background(), nil, nil, + map[string]any{"html": ""}, + nil, nil, + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + out, _ := res.Output["html"].(string) + if !strings.Contains(out, "'anonymize_ip': true") { + t.Errorf("step did not honour anonymize_ip flag; got %q", out) + } + if !strings.Contains(out, "G-TENANTA") { + t.Errorf("step missing tenant tag_id; got %q", out) + } +} + +func TestAnalyticsInjectHTMLStepPerCallTagID(t *testing.T) { + // Multi-tenant invocation: tag_id passed at execute time (e.g. from + // tenant-resolved context) rather than configured at module build. + step, err := newAnalyticsInjectHTMLStep("inj", map[string]any{}) + if err != nil { + t.Fatalf("new step: %v", err) + } + res, err := step.Execute( + context.Background(), nil, nil, + map[string]any{"html": ""}, + nil, + map[string]any{"tag_id": "G-TENANTB", "anonymize_ip": true}, + ) + if err != nil { + t.Fatalf("execute: %v", err) + } + out, _ := res.Output["html"].(string) + if !strings.Contains(out, "G-TENANTB") { + t.Errorf("per-call tag_id not honoured; got %q", out) + } + if !strings.Contains(out, "'anonymize_ip': true") { + t.Errorf("per-call anonymize_ip not honoured; got %q", out) + } +}