npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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: resolver

The 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-remote

The 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:

  • 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.

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.
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>
    );
}