sourceafGoogleAnalytics::GoogleAnalytics.fan

using afIoc
using afIocConfig
using afDuvet
using afBedSheet::HttpRequest
using afBedSheet::HttpResponse
using afBedSheet::BedSheetServer
using util::JsonOutStream

** (Service) - 
** Renders the Google Universal Analytics script and sends page views and events.
** 
** See [analytics.js]`https://developers.google.com/analytics/devguides/collection/analyticsjs/` for details.
const mixin GoogleAnalytics {

    ** Returns the domain used to setup the google script. 
    abstract Str? accountDomain()

    ** Returns the account used to setup the google script. 
    abstract Str accountNumber()

    ** Returns 'true' if the page has already rendered Javascript to send a page view event.
    ** 
    ** This allows individual pages to send page views for canonical URLs and a layout component to 
    ** send general page views if the page hasn't done so. 
    abstract Bool pageViewRendered()

    ** Renders Javascript to send a page view to google analytics. If 'url' is given then it should start with a leading '/', e.g. '/about'
    ** 
    ** Note that if a URL is NOT supplied, then the query string is stripped from the rendered URL. 
    ** This usually makes sense for Fantom web apps as query strings do not generally denote unique pages. 
    abstract Void renderPageView(Uri? url := null, [Str:Obj?]? fieldOptions := null)

    ** Renders Javascript to send an event to google analytics. 
    abstract Void renderEvent(Str category, Str action, Str? label := null, Num? value := null, [Str:Obj?]? fieldOptions := null)

    ** Renders Javascript to add an arbitrary command to the command queue. Example:
    ** 
    **   syntax: fantom
    **   renderCmd("create", "UA-XXXXX-Y", "auto")
    ** 
    ** would render:
    **  
    **   syntax: javascript
    **   ga('create', 'UA-XXXXX-Y', 'auto');
    ** 
    ** Arguments have 'toStr' executed on them before rendering.
    ** 
    ** If the last argument is a Map, then it is serialised as JSON and used as the 'fieldsObject'. Example:
    ** 
    **   syntax: fantom
    **   renderCmd("create", [
    **       "trackingId"   : "UA-XXXXX-Y",
    **       "cookieDomain" : "auto"
    **   ])
    ** 
    ** would render:
    **  
    **   syntax: javascript
    **   ga('create', {
    **       'trackingId'   : 'UA-XXXXX-Y',
    **       'cookieDomain' : 'auto'
    **   });
    **  
    abstract Void renderCmd(Str cmd, Obj? arg1 := null, Obj? arg2 := null, Obj? arg3 := null, Obj? arg4 := null)
}

internal const class GoogleAnalyticsImpl : GoogleAnalytics {
    @Inject private const Log log
    
    @Config
    @Inject override const Str accountNumber

    @Config { id="afGoogleAnalytics.accountDomain" }
    @Inject private const Uri googleDomain

    @Config { id="afIocEnv.isProd" }
    @Inject private const Bool? isProd

    @Inject private const BedSheetServer    bedServer
    @Inject private const HttpRequest       httpReq
    @Inject private const HttpResponse      httpRes
    @Inject private const HtmlInjector      injector
            private const Bool              renderScripts
            override const Str?             accountDomain

    new make(|This|in) {
        in(this)
        borked := false
        if (accountNumber.isEmpty) {
            log.warn("Google Analytics Account Number has not been set.\n Add the following to your AppModule's contributeApplicationDefaults() method:\n   config[${GoogleAnalyticsConfigIds#.name}.${GoogleAnalyticsConfigIds#accountNumber.name}] = \"UA-XXXXX-Y\");")
            borked = true
        }

        if (googleDomain.toStr.all { it.isAlphaNum || it == '.' })
            accountDomain = googleDomain.toStr
        else
            accountDomain = googleDomain.toStr.trim.isEmpty ? bedServer.host.host : googleDomain.host

        if (isProd && (accountDomain == null || accountDomain.lower.contains("localhost"))) {
            log.warn("Google Analytics Domain `${accountDomain}` is not valid'!\n Add the following to your AppModule's contributeApplicationDefaults() method:\n   config[${GoogleAnalyticsConfigIds#.name}.${GoogleAnalyticsConfigIds#accountDomain.name}] = \"http://www.example.com\");")
            borked = true
        }

        renderScripts = isProd && !borked
    }
    
    override Bool pageViewRendered() {
        httpReq.stash["afGoogleAnalytics.pageViewRendered"] == true
    }

    override Void renderPageView(Uri? url := null, [Str:Obj?]? fieldOptions := null) {
        if (renderScripts || log.isDebug) {
            if (renderScripts) renderGuas
            code := StrBuf()            
            join := |Obj? obj| { code.join(JsonOutStream.writeJsonToStr(obj), ", ") }
            join("send")
            join("pageview")
            join(url ?: httpReq.urlAbs.pathOnly.encode) // pathOnly to cut off any query string --> Fantom apps are not CGI / PHP scripts!
            if (fieldOptions != null) join(fieldOptions)

            if (renderScripts)
                injector.injectScript.withScript("ga(${code});")
            if (log.isDebug)
                log.debug("ga(${code});")
        }
        httpReq.stash["afGoogleAnalytics.pageViewRendered"] = true
    }

    override Void renderEvent(Str category, Str action, Str? label := null, Num? value := null, [Str:Obj?]? fieldOptions := null) {
        if (renderScripts || log.isDebug) {
            if (renderScripts) renderGuas
            code := StrBuf()            
            join := |Obj? obj| { code.join(JsonOutStream.writeJsonToStr(obj), ", ") }
            join("send")
            join("event")
            join(category)
            join(action)
            if (label != null || value != null || fieldOptions != null) join(label)
            if (                 value != null || fieldOptions != null) join(value)
            if (                                  fieldOptions != null) join(fieldOptions)

            if (renderScripts)
                injector.injectScript.withScript("ga(${code});")
            if (log.isDebug)
                log.debug("ga(${code});")
        }
    }
    
    override Void renderCmd(Str cmd, Obj? arg1 := null, Obj? arg2 := null, Obj? arg3 := null, Obj? arg4 := null) {
        if (renderScripts || log.isDebug) {
            if (renderScripts) renderGuas

            args := [cmd, arg1, arg2, arg3, arg4].exclude { it == null }
            code := args.map |arg, i| {
                i == args.size - 1 && arg is Map
                    ? arg
                    : arg.toStr
            }.map {
                JsonOutStream.writeJsonToStr(it)
            }.join(", ")
            
            if (renderScripts)
                injector.injectScript.withScript("ga(${code});")
            if (log.isDebug)
                log.debug("ga(${code});")
        }
    }
    
    private Void renderGuas() {
        injector.injectScript.withScript(
            "(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
             (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
             m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
             })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
             ga('create', '${accountNumber}', '${accountDomain}');")

        csp := httpRes.headers.contentSecurityPolicy
        addCsp(csp, "script-src",   "https", "https://www.google-analytics.com")    // for the main script
        addCsp(csp, "img-src",      "https", "https://www.google-analytics.com")    // for some tracking pixel
        addCsp(csp, "connect-src",  "https", "https://www.google-analytics.com")    // because of the odd CSP report 
        httpRes.headers.contentSecurityPolicy = csp

        cspro := httpRes.headers.contentSecurityPolicyReportOnly
        addCsp(cspro, "script-src",     "https", "https://www.google-analytics.com")    // for the main script
        addCsp(cspro, "img-src",        "https", "https://www.google-analytics.com")    // for some tracking pixel
        addCsp(cspro, "connect-src",    "https", "https://www.google-analytics.com")    // because of the odd CSP report 
        httpRes.headers.contentSecurityPolicyReportOnly = cspro
    }
    
    // this handy method was nabbed and updated from Duvet
    private static Bool addCsp([Str:Str]? csp, Str dirName, Str altDir, Str newDir) {
        if (csp == null)
            return false

        directive   := csp[dirName]?.trimToNull ?: csp["default-src"]?.trimToNull
        if (directive == null)
            return false

        directives  := directive.split
        if (directives.contains(altDir))    // e.g. 'unsafe-inline'
            return false
        
        if (directives.contains(newDir))
            return false
        
        csp[dirName] = directive.replace("'none'", "") + " " + newDir
        return true
    }
}