npm package: @marvelution/forged-with-posthog
Description: Forged with PostHog
Author: Marvelution
Latest version: 0.15.0

# PostHog Analytics implementation compatible with Atlassian Forge

This module is a wrapper, `AnalyticsProvider` around the `posthost-js` SDK to make it compatible with Atlassian Forge and uses 
either a resolver function or a remote endpoint to get the configuration for PostHog.

## Using a Forge Function

Below shows the `manifest` snippet where a `resolver` `function` has been configured to get the PostHog configuration.

```yaml
modules:
  function:
  - key: resolver
    handler: index.handler
  jira:issuePanel:
  - key: issue-content
    title: My Panel
    resource: ui-resource
    render: native
    resolver:
      function: resolver
```

The `AnalyticsProvider` will use `invoke()` to get the [PostHog](https://posthog.com) configuration using the function provided in the `resolver` property.

```javascript
import Resolver from '@forge/resolver';
import {PostHogConfig} from "@marvelution/forged-with-posthog";

const resolver = new Resolver();
resolver.define('posthog-config', async ({payload, context}): Promise<PostHogConfig> => {
  if (!process.env.POSTHOG_API_KEY) {
    throw new Error('POSTHOG_API_KEY environment variable is not set. Please set it using `forge variables set --encrypt POSTHOG_API_KEY "your-actual-api-key"`');
    // NOTE: Make sure you've set the POSTHOG_API_KEY variable in the correct Forge app environment
    // NOTE: If you're using forge tunnels, export FORGE_USER_VAR_POSTHOG_API_KEY="your-actual-api-key" instead
  }
  const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY;

  if (!process.env.POSTHOG_API_HOST) {
    // For the public servers this might be https://eu.i.posthog.com or https://us.i.posthog.com
    // For forge remote proxies, it might be https://${process.env.FORGE_REMOTE_ENDPOINT}/proxy/posthog
    throw new Error('POSTHOG_API_HOST environment variable is not set. Please set it using `forge variables set POSTHOG_API_HOST "https://your-posthog-instance.com"`');
  }
  const POSTHOG_API_HOST = process.env.POSTHOG_API_HOST;

  // Optional
  const POSTHOG_UI_HOST = process.env.POSTHOG_UI_HOST;

  return {
    apiKey: POSTHOG_API_KEY,
    apiHost: POSTHOG_API_HOST,
    uiHost: POSTHOG_UI_HOST,
  };
});
export const handler = resolver.getDefinitions();
```

## Using a Forge Remote

Below shows the `manifest` snippet where an `endpoint` `resolver` has been configured to get the PostHog configuration.

```yaml
remotes:
- key: forge-remote
  baseUrl: https://${FORGE_REMOTE_ENDPOINT}
  operations:
  - compute
  - fetch
  auth:
    appSystemToken:
      enabled: true
    appUserToken:
      enabled: true
modules:
  endpoint:
  - key: ui-remote
    remote: forge-remote
    auth:
      appUserToken:
        enabled: true
  jira:issuePanel:
  - key: issue-content
    title: My Panel
    resource: ui-resource
    render: native
    resolver:
      endpoint: ui-remote
```

The `AnalyticsProvider` will use `invokeRemote()` to get the [PostHog](https://posthog.com) configuration using the 
path provided in the `remote` property. This endpoint should return the following JSON:

```json
{
  "apiKey": "[your api key]",
  "apiHost": "https://${FORGE_REMOTE_ENDPOINT}/proxy/posthog",
  "uiHost": "https://eu.posthog.com"
}
```

You can also use `https://us.posthog.com` if you want to use the US Cloud of PostHog.
When using the US cloud of PostHog, then also make sure to use `us-assets.i.posthog.com` and `us.i.posthog.com` in your reverse proxy!

PostHog recommends using a [reverse proxy](https://posthog.com/docs/advanced/proxy) to the PostHog cloud, and the `AnalyticsProvider`
has been implemented to use a proxy hosted by a Forge Remote of the Forge app.
To configure a reverse proxy, you can follow the documentation linked above.

### CloudFront Reverse Proxy Reference Implementation

The CloudFront Edge function is used to handle proxy and configuration requests to PostHog.
```javascript
import {GetSecretValueCommand, SecretsManagerClient} from "@aws-sdk/client-secrets-manager";
import {JwtVerifier} from "aws-jwt-verify";

let verifier = undefined;
let config = undefined;

export const handler = async (event) => {
    const request = event.Records[0].cf.request;

    // Strip the proxy path prefix
    request.uri = request.uri.replace(/^(\/proxy\/posthog)/, '');

    if (request.uri === '/config' && request.headers['authorization'] !== undefined) {
        try {
            // verify that the request comes from a user of your app if the request is for the configuration.
            await verifyForgeInvocationToken(request.headers['authorization'][0].value);

            if (config === undefined) {
                const client = new SecretsManagerClient({region: 'us-east-1'});
                const command = new GetSecretValueCommand({
                    SecretId: request.origin.custom.customHeaders['posthog-proxy-secret'][0].value,
                });
                const response = await client.send(command);
                config = JSON.parse(response.SecretString);
            }

            return {
                status: 200,
                headers: {
                    'content-type': [{
                        key: 'Content-Type',
                        value: 'application/json',
                    }],
                },
                body: JSON.stringify({
                    ...config,
                    apiHost: `https://${request.origin.custom.customHeaders['posthog-proxy-host'][0].value}/proxy/posthog`
                })
            }
        } catch (e) {
            console.error('Failed process config request', e);
            return {
                status: 400
            }
        }
    }

    request.headers.host[0].value = request.origin.custom.domainName;

    return request;
}

const verifyForgeInvocationToken = async (token) => {
    if (verifier === undefined) {
        verifier = JwtVerifier.create({
            jwksUri: "https://forge.cdn.prod.atlassian-dev.net/.well-known/jwks.json",
            issuer: "forge/invocation-token",
            audience: null,
            graceSeconds: 10,
        });
    }
    // Only verify the token, if you know your app id at this point then you can also verify the intended 'audience' of the token.
    await verifier.verify(token.replace(/^Bearer\s+/i, ""));
}
```

This Edge function implementation does require package `aws-jwt-verify` version `5.1.0` or newer to be installed!

See below the AWS CDK reference implementation snippet to deploy the proxy.

```java
Distribution distribution = Distribution.Builder.create(this, "CloudFront")
        // Additional configuration of the CloudFront distribution
        .build();

// The Proxy Origin and Behaviours
String functionName = createResourceName("PostHogProxy");
Role postHogProxyFunctionRole = Role.Builder.create(this, "PostHogProxyFunctionRole")
        .roleName(createGlobalResourceName("PostHogProxyRole"))
        .assumedBy(ServicePrincipal.Builder.create("lambda.amazonaws.com").build())
        .maxSessionDuration(Duration.hours(1))
        .path("/")
        .managedPolicies(List.of(ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")))
        .build();
postHogProxyFunctionRole.applyRemovalPolicy(RemovalPolicy.DESTROY);

// The secret used to store the API KEY
Secret postHogProxySecret = Secret.Builder.create(this, "PostHogProxySecret")
        .secretName(createSecretName("PostHogProxySecret"))
        .description("PostHog Proxy Secret")
        .build();
postHogProxySecret.grantRead(postHogProxyFunctionRole);

String projectRoot = "./lambdas/posthog-proxy";
NodejsFunction postHogProxyFunction = NodejsFunction.Builder.create(this, "PostHogProxyNodejsFunction")
        .functionName(functionName)
        .runtime(Runtime.NODEJS_22_X)
        .role(postHogProxyFunctionRole)
        .projectRoot(projectRoot)
        .entry(projectRoot + "/index.mjs")
        .depsLockFilePath(projectRoot + "/package-lock.json")
        .bundling(BundlingOptions.builder()
                .forceDockerBundling(true)
                .command(List.of("npm", "run", "asset-bundle"))
                .workingDirectory("/asset-input")
                .build())
        .handler("index.handler")
        .logRetention(RetentionDays.ONE_WEEK)
        .build();

// Retain the proxy function so that at least the stack can be destroyed, just leaving this resource to be cleaned up manually
postHogProxyFunction.applyRemovalPolicy(RemovalPolicy.RETAIN);

CachePolicy postHogProxyCachePolicy = CachePolicy.Builder.create(this, "PostHogProxyCachePolicy")
        .cachePolicyName(createResourceName("PostHogProxyCachePolicy"))
        .comment("Policy specific for the PostHog proxy")
        .headerBehavior(CacheHeaderBehavior.allowList("origin", "authorization"))
        .queryStringBehavior(CacheQueryStringBehavior.all())
        .cookieBehavior(CacheCookieBehavior.none())
        .build();
OriginRequestPolicy postHogOriginRequestPolicy = OriginRequestPolicy.Builder.create(this, "PostHogProxyOriginRequestPolicy")
        .originRequestPolicyName(createResourceName("PostHogProxyOriginRequestPolicy"))
        .comment("Policy specific for the PostHog assets proxy")
        .headerBehavior(OriginRequestHeaderBehavior.allowList("origin"))
        .queryStringBehavior(OriginRequestQueryStringBehavior.all())
        .cookieBehavior(OriginRequestCookieBehavior.none())
        .build();
Map<String, String> customHeaders = Map.of("posthog-proxy-secret",
        postHogProxySecret.getSecretArn(),
        "posthog-proxy-host",
        "[YOUR_DISTRIBUTION_DOMAIN_NAME]");

distribution.addBehavior("/proxy/posthog/static/*",
        HttpOrigin.Builder.create("eu-assets.i.posthog.com")
                .httpsPort(443)
                .protocolPolicy(OriginProtocolPolicy.HTTPS_ONLY)
                .connectionAttempts(3)
                .connectionTimeout(Duration.seconds(10))
                .readTimeout(Duration.seconds(30))
                .keepaliveTimeout(Duration.seconds(5))
                .customHeaders(customHeaders)
                .build(),
        AddBehaviorOptions.builder()
                .viewerProtocolPolicy(ViewerProtocolPolicy.HTTPS_ONLY)
                .allowedMethods(AllowedMethods.ALLOW_ALL)
                .cachedMethods(CachedMethods.CACHE_GET_HEAD)
                .cachePolicy(postHogProxyCachePolicy)
                .originRequestPolicy(postHogOriginRequestPolicy)
                .responseHeadersPolicy(ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT_AND_SECURITY_HEADERS)
                .edgeLambdas(List.of(EdgeLambda.builder()
                        .eventType(LambdaEdgeEventType.ORIGIN_REQUEST)
                        .functionVersion(postHogProxyFunction.getCurrentVersion())
                        .build()))
                .build());
distribution.addBehavior("/proxy/posthog/*",
        HttpOrigin.Builder.create("eu.i.posthog.com")
                .httpsPort(443)
                .protocolPolicy(OriginProtocolPolicy.HTTPS_ONLY)
                .connectionAttempts(3)
                .connectionTimeout(Duration.seconds(10))
                .readTimeout(Duration.seconds(30))
                .keepaliveTimeout(Duration.seconds(5))
                .customHeaders(customHeaders)
                .build(),
        AddBehaviorOptions.builder()
                .viewerProtocolPolicy(ViewerProtocolPolicy.HTTPS_ONLY)
                .allowedMethods(AllowedMethods.ALLOW_ALL)
                .cachedMethods(CachedMethods.CACHE_GET_HEAD)
                .cachePolicy(postHogProxyCachePolicy)
                .originRequestPolicy(postHogOriginRequestPolicy)
                .responseHeadersPolicy(ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT_AND_SECURITY_HEADERS)
                .edgeLambdas(List.of(EdgeLambda.builder()
                        .eventType(LambdaEdgeEventType.ORIGIN_REQUEST)
                        .functionVersion(postHogProxyFunction.getCurrentVersion())
                        .build()))
                .build());
```

### Development Reverse Proxy Reference Implementation

See below a reference implementation of the PostHog reverse proxy in Java Servlet. 
Register this servlet with url mapping `/proxy/posthog/*` and make sure it can be accessed anonymously.

```java
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.web.util.UriComponentsBuilder;

public class PostHogProxyServlet
        extends HttpServlet
{
    private static final int TIMEOUT = 10000;

    @Override
    protected void service(
            HttpServletRequest request,
            HttpServletResponse response)
            throws IOException
    {
        try
        {
            URI targetUri = buildTargetUri(request);

            if (Objects.equals(targetUri.getPath(), "/config") && request.getHeader(HttpHeaders.AUTHORIZATION) != null)
            {
                addCorsAndSecurityHeaders(response);
                response.setStatus(200);
                response.setContentType("application/json");
                JsonObject config = Json.createObjectBuilder(Map.of("apiKey",
                                System.getenv("POSTHOG_API_KEY"),
                                "apiHost",
                                "https://" + System.getenv("FORGE_REMOTE_ENDPOINT") + "/proxy/posthog",
                                "uiHost",
                                "https://eu.posthog.com"))
                        .build();
                Json.createWriter(response.getWriter())
                        .writeObject(config);
                return;
            }

            HttpURLConnection conn = (HttpURLConnection) targetUri.toURL()
                    .openConnection();
            conn.setConnectTimeout(TIMEOUT);
            conn.setReadTimeout(TIMEOUT);
            conn.setRequestMethod(request.getMethod());

            copyHeaders(request).forEach((name, values) -> values.forEach(value -> conn.setRequestProperty(name, value)));

            boolean hasBody = request.getContentLength() > 0;
            conn.setDoInput(true);
            conn.setDoOutput(hasBody);

            if (hasBody)
            {
                try (var os = conn.getOutputStream())
                {
                    byte[] body = readRequestBody(request);
                    if (body != null && body.length > 0)
                    {
                        os.write(body);
                    }
                }
            }

            conn.connect();
            copyResponseToClient(conn, response);
        }
        catch (Exception e)
        {
            handleProxyError(e, response);
        }
    }

    private URI buildTargetUri(
            HttpServletRequest request)
    {
        String requestUri = request.getRequestURI()
                .replaceFirst("/proxy/posthog", "");

        String host = requestUri.startsWith("/static/") ? "eu-assets.i.posthog.com" : "eu.i.posthog.com";

        return UriComponentsBuilder.newInstance()
                .scheme("https")
                .host(host)
                .path(requestUri)
                .query(request.getQueryString())
                .build(true)
                .toUri();
    }

    private HttpHeaders copyHeaders(HttpServletRequest request)
    {
        HttpHeaders headers = new HttpHeaders();
        Collections.list(request.getHeaderNames())
                .forEach(headerName -> headers.set(headerName, request.getHeader(headerName)));
        headers.remove(HttpHeaders.ACCEPT_ENCODING);
        return headers;
    }

    private byte[] readRequestBody(HttpServletRequest request)
            throws IOException
    {
        if (request.getContentLength() > 0)
        {
            return request.getInputStream()
                    .readAllBytes();
        }
        return null;
    }

    private void copyResponseToClient(
            HttpURLConnection conn,
            HttpServletResponse response)
            throws IOException
    {
        response.setStatus(conn.getResponseCode());

        conn.getHeaderFields()
                .forEach((name, values) -> {
                    if (name != null)
                    {
                        values.forEach(value -> response.addHeader(name, value));
                    }
                });

        addCorsAndSecurityHeaders(response);

        try (var is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream())
        {
            if (is != null)
            {
                response.getOutputStream()
                        .write(is.readAllBytes());
            }
        }
    }

    private void handleProxyError(
            Exception e,
            HttpServletResponse response)
            throws IOException
    {
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        response.getWriter()
                .write("Proxy error: " + e.getMessage());
    }

    private static void addCorsAndSecurityHeaders(HttpServletResponse response)
    {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Headers", "*");
        response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Max-Age", "3600");

        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("X-Frame-Options", "DENY");
        response.setHeader("X-XSS-Protection", "1; mode=block");
        response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
        response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    }
}
```

## Analytics

To use this module, after setup, wrap your application React component with the `<AnalyticsProvider/>` and specify either a `resolver` 
function key or a `remote` path, like so:
            
Sample when using a `resolver` `function`:
```javascript
ForgeReconciler.render(
    <React.StrictMode>
        <AnalyticsProvider resolver={'posthog-config'}>
            <App/>
        </AnalyticsProvider>
    </React.StrictMode>
);
```

Sample when using a `resolver` `endpoint`:
```javascript
ForgeReconciler.render(
    <React.StrictMode>
        <AnalyticsProvider endpoint={'/proxy/posthog/config'}>
            <App/>
        </AnalyticsProvider>
    </React.StrictMode>
);
```

Then within your `<App/>` component use `useAnalytics()` to get `group`, `identify` and `track` functions for analytics, like so:
`FullContext` is used to populate properties that are also sent to PostHog.

```javascript
export const App = () => {
    const context = useProductContext();
    if (!context) return <Loading size={"large"}/>;

    const {group, identify, track} = useAnalytics();

    group(context);
    identify(context);
    
    track(context, 'my-event-to-capture');
    // Rest of your component
};

```

## Surveys

The PostHog SDK is not able to render surveys by itself because Forge blocks access to the `window`and `document` objects of the main 
application. Therefor a workaround is needed, and this is where the `useSurvey(id)` hooks comes in.

Using `useSurvey(id)` will return an object with:
* `status` either `unknown` or `ready`,
* `survey` a React component of the rendered survey within a Forge UI Modal,
* `showSurvey()` a method to show the survey modal,
* `dismissSurvey()` a method to dismiss the survey or close the modal,
* `eligibility()` of a survey, either `true`, `false`, or `unknown`. 
  If `true` then the survey can be presented to the user. 
  
the `useSurvey(id, callback)` is an additional variant of the hook. The callback is called when the survey Modal is closed either by 
sending of dismissing it.  

```javascript
export const App = () => {
    const {showSurvey, survey} = useSurvey('survey-id');
    
    return (
        <>
            {survey}
            <Button onClick={showSurvey}>Show Survey</Button>
        </>
    );
}
```

## Feedback

The `useFeedback(id)` is a special use of `useSurvey(id)`, it returns an objects with:
* `survey` a React component of the rendered survey with a Forge UI Modal,
* `button` a `<Button/>` component that can be used to open the feedback survey,
* `bar` a special notification bar with links to open the feedback survey or dismiss it. The `bar` is only set if the survey is eligible,
  ie `survey.eligibility()` returns `true`. 

```javascript
export const App = () => {
    const {bar, button, survey} = usefeedback('feedback-survey-id');
    
    return (
        <Stack space={"space.100"}>
            {survey}
            {bar}
            <Inline alignInline={"center"} alignBlock={"center"} grow={"fill"}>
                <Box xcss={xcss({width: "100%", paddingLeft: "space.100", paddingRight: "space.100"})}>
                    <Heading size={"medium"}>My Page</Heading>
                </Box>

                <Inline alignInline={"center"} alignBlock={"center"} grow={"hug"}>
                    <Button appearance={"subtle"} spacing={"compact"}
                            onClick={() => navigate(-1)}
                            iconBefore={"undo"}>Back</Button>
                    {button}
                </Inline>
            </Inline>
            ...
        </Stack>
    );
}
```