Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>`.
Expand Down
12 changes: 7 additions & 5 deletions internal/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
31 changes: 18 additions & 13 deletions internal/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -148,7 +149,7 @@ func injectHTML(input, provider, tagID string) (string, error) {
}
switch provider {
case ProviderGoogleAnalytics:
return injectBeforeClosingTag(htmlDoc, "</head>", googleAnalyticsBlock(tagID))
return injectBeforeClosingTag(htmlDoc, "</head>", googleAnalyticsBlock(tagID, anonymizeIP))
case ProviderGoogleTagManager:
withHead, err := injectBeforeClosingTag(htmlDoc, "</head>", googleTagManagerHeadBlock(tagID))
if err != nil {
Expand Down Expand Up @@ -230,19 +231,23 @@ func blockEnd(provider, slot string) string {
return fmt.Sprintf("<!-- workflow-plugin-analytics:%s:%s:end -->", 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
<script async src="https://www.googletagmanager.com/gtag/js?id=%s"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '%s');
%s
</script>
%s
`, blockStart(ProviderGoogleAnalytics, "head"), idURL, idJS, blockEnd(ProviderGoogleAnalytics, "head"))
`, blockStart(ProviderGoogleAnalytics, "head"), idURL, configCall, blockEnd(ProviderGoogleAnalytics, "head"))
}

func googleTagManagerHeadBlock(tagID string) string {
Expand Down
36 changes: 28 additions & 8 deletions internal/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const sampleHTML = `<!doctype html>
`

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)
}
Expand All @@ -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)
}
Expand All @@ -53,7 +53,7 @@ func TestInjectHTML_GoogleAnalyticsSkipsUnmanagedSameTag(t *testing.T) {
<script>gtag('config', 'G-EXISTING');</script>
</head>`, 1)

got, err := injectHTML(existing, ProviderGoogleAnalytics, "G-EXISTING")
got, err := injectHTML(existing, ProviderGoogleAnalytics, "G-EXISTING", false)
if err != nil {
t.Fatalf("injectHTML: %v", err)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -97,7 +97,7 @@ func TestInjectHTML_GoogleTagManagerSkipsUnmanagedSameTag(t *testing.T) {
existing := strings.Replace(sampleHTML, "</head>", `<script src="https://www.googletagmanager.com/gtm.js?id=GTM-EXISTING"></script>
</head>`, 1)

got, err := injectHTML(existing, ProviderGoogleTagManager, "GTM-EXISTING")
got, err := injectHTML(existing, ProviderGoogleTagManager, "GTM-EXISTING", false)
if err != nil {
t.Fatalf("injectHTML: %v", err)
}
Expand Down Expand Up @@ -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)
}
}
20 changes: 14 additions & 6 deletions internal/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
50 changes: 50 additions & 0 deletions internal/step_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<html><head></head><body></body></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": "<html><head></head><body></body></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)
}
}
Loading