@marvelution/forged-with-posthog
v0.15.0
Published
Forged with PostHog
Readme
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.
modules:
function:
- key: resolver
handler: index.handler
jira:issuePanel:
- key: issue-content
title: My Panel
resource: ui-resource
render: native
resolver:
function: resolverThe AnalyticsProvider will use invoke() to get the PostHog configuration using the function provided in the resolver property.
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.
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-remoteThe AnalyticsProvider will use invokeRemote() to get the PostHog configuration using the
path provided in the remote property. This endpoint should return the following 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 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.
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.
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.
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:
ForgeReconciler.render(
<React.StrictMode>
<AnalyticsProvider resolver={'posthog-config'}>
<App/>
</AnalyticsProvider>
</React.StrictMode>
);Sample when using a resolver endpoint:
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.
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 windowand 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:
statuseitherunknownorready,surveya 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, eithertrue,false, orunknown. Iftruethen 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.
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:
surveya React component of the rendered survey with a Forge UI Modal,buttona<Button/>component that can be used to open the feedback survey,bara special notification bar with links to open the feedback survey or dismiss it. Thebaris only set if the survey is eligible, iesurvey.eligibility()returnstrue.
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>
);
}