Skip to content

[EPIC] PostHog Apps implementation #9479

Closed as not planned
Closed as not planned

Description

@mariusandra

PostHog Apps

By now I'll assume you have all seen the demo from PR #9458 feat(apps): very dodgy frontend apps implementation

2022-04-21 10 14 06

That's a dodgy PR, but shows what's possible. There, plugins gain a new frontend field, whose JS is separately downloaded, parsed and loaded on the frontend. It currently exposes a sidebar label, a React component, and a Kea logic connecting it, which can already be used to override breadcrumbs. All of that is injected on the fly, but, in a dodgy MVP way.

A lot is missing, including injecting code into your site, and any kind of persistence.

I see a lot of potential in this, and having spoken to many people about this, most share the enthusiasm, especially @jamesefhawkins and @corywatilo

The vision

This means different things to different people, but, as a website owner, I will add posthog to my site, and that's it. Everything else that my site will use, will come through PostHog. I'll sign up to PostHog, add the widget, enable session recordings, and comments (through the squeak app). Later on I'll inject the google analytics JS snippet through the PostHog Tag Manager app, and show Google just who's the boss here.

Basically, PostHog with its extra list of apps that I can pick depending on the needs of the site I'm instrumenting, will become the backoffice of my entire static website.

What's an app

After speaking to a few people (incl @corywatilo last night), we seem to now all be on the same page as to what's an app and what's not an app.

Basically: if you can build it as an app, it's an app.

The flip side: you can't really build anything as an app yet. Except plugins. All plugins are apps. We should rename the tab, and eventually give it a facelift. Just some apps can very soon have a frontend/sidebar component, and can soon after do even cooler things (HTTP requests, persistence, inject code on your site).

This means everything that's currently there in the PostHog App, remains as part of the PostHog App, as you can't build any of it as an app. Insights, feature flags, session recordings, etc are all core features of the PostHog platform that PostHog Apps can use as building blocks. The example is the "squack" app from above, which integrates a custom insight.

The "Web Performance" page as it stands now could be an app. It's built using existing components (basically the events list), and talks to existing APIs. You could enable it, and 1) something would start tracking performance data on posthog-js, and 2) you'd get a new page in the sidebar that lets you explore this data.

Next steps

Here are the next steps to make PostHog apps a reality:

  • Create a separate plugin installation step, which includes transpiling, bundling, and caching JS/TS. Integrate esbuild into the plugin server, and bundle the frontend.tsx file or frontend/ folder into a JS file we can import on the frontend.

  • Replace the eval dodgy AF proof of concept with a solution that imports the bundled plugin frontend scripts as ES modules via HTTP. These will be the cached JS bundles from the previous step, simply served from postgres.

  • Rename the "plugin editor"'s two source windows to "Backend" and "Frontend" (or into three - "Ingestion", "Backend" and "Frontend"?), and allow users to write React components right within the PostHog app.

  • Make a stable and synchronous django <-> plugin server task bridge ... or expose a HTTP endpoint directly onto the plugin server.

  • Add support for an endpoints object in the "backend" part of the plugin. Endpoints are, you guessed it, HTTP endpoints, which can be called from the frontend, through django, through redis, through piscina, into the worker VM, and back again. Slow, but make it work, then make it fast. These endpoints could be used to use the cache and storage APIs available for plugins for persistence.

  • Add support for dynamically injecting JS into your website through posthog-js, and exposing that as a feature for app developers.

  • Create a list of React components (e.g. Lemon*) exported to apps, and polish the storybook for public consumption.

  • Create a local development bundle, which spins up a comparable esbuild-based local dev server, connected to a local or cloud posthog API, and lets you build your app's frontend and its tests.

  • Eventually, 6 months from now, add support for payments and create a healthy ecosystem.

  • Also, dogfood the platform by building these high quality apps:

    • Squeak / Comments
    • In-site Notifications
    • NPS Survey
    • Product Tour / wizard
    • Forms (bye bye google forms)
    • HTML Injector (bye bye google tag manager)
    • SEO Optimizer aka the HTML meta tag configurator
    • Live World Map flippy spinny globe thing

Who will do all of this?

I'd be quite happy to take on some of the work, and expand the plugin app server with these cool new features in the next sprints. It's really not that much work 😅 .

Activity

  1. neilkakkar commented on Apr 21, 2022

    @neilkakkar
    Contributor

    Much hype! To add to high quality apps: This unlocks running front-end experiments without code changes, which is something product-owners-who-dont-want-to-wait-on-engineers really want!

  2. joesaunderson commented on Apr 21, 2022

    @joesaunderson
    Contributor

    Commenting from a Squeak background, it'd be great to be able to ditch Supabase.

    How would the apps implementation work in terms of:

    • DB, would each app get the ability to create tables in Postgres? Or would everything be stored as events in PostHog / Clickhouse.
    • Auth, for Squeak, users have to login on the client site - how would the apps handle this?
  3. mariusandra commented on Apr 21, 2022

    @mariusandra
    CollaboratorAuthor

    @joesaunderson The answer depends mostly on if, and when, we can implement this part:

    Add support for an endpoints object in the "backend" part of the plugin. Endpoints are, you guessed it, HTTP endpoints, which can be called from the frontend, through django, through redis, through piscina, into the worker VM, and back again. Slow, but make it work, then make it fast. These endpoints could be used to use the cache and storage APIs available for plugins for persistence.

    (@yakkomajuri is this a 1h pairing task?)

    If we have this endpoint support, we can use the APIs available in a plugin for persistence. This includes cache - a namespaced redis client, and storage - postgres-backed persistent JSON storage.

    One approach we could take would be something like this:

    export const endpoints = ({ storage, cache, jobs }) => ({
      // POST /api/plugin_endpoints/:uuid/saveComment ends up here
      saveComment: async (req, res) => {
        const id = uuid()
        const comment = { id, ...req.body.comment } 
        await storage.set(`${req.body.postId}/comment/${id}`, comment)
        res.status(200)
        res.text('ok')
      },
      // GET /api/plugin_endpoints/:uuid/getComments/:postId ends up here
      getComments: async (req, res) => {
        // todo: cache this in s3 and serve from there
        const postId = req.url.split('/').filter(a => !!a).pop()
        // TODO: 'storage.getStartsWith' doesn't exist (only .get exists), but it can be built
        const comments = await storage.getStartsWith(`${postId}/comment/`)
        res.json(comments)
      }
    })

    Authentication can be solved by either implementing our own oauth client endpoints, or solving it as a common solution all plugins can use. Either way works.

    If we don't have endpoints, then except for authentication, we could still build most of squeak with PostHog's plugins today. This approach would store all comments as PostHog events in ClickHouse via a simple posthog.capture().

    Then onEvent, we'd use posthog's own api.get(...) to get the list of events for this page from clickhouse, remove the ones that had a follow-up moderation event, and store that JSON blob in S3 for easy access. Squeak's frontend will then use the S3 bucket as a read source.

    We'll need to be careful with timings (the event might be queryable only a second after ingestion?), but some clever use of redis will probably suffice. The comments might also get huge on some pages, and spam can become a problem. I'm not fully sure what are the limits of this system.

    This will also require a bit of innovation for authenticated events, to prevent abusers from creating fake comment moderation reports... but nothing complex. A plugin that compares a hashed key with a salt only your backend knows should be enough.

    Yet, we might want to store all comments as posthog events anyway, just to be able to do nice charts... and perhaps so we would have a very scalable platform. storage is postgres, and does have its limits...

  4. jamesefhawkins commented on Apr 22, 2022

    @jamesefhawkins
    Contributor

    An annoying (perhaps the most annoying) thing with squeak! is we want users to be able to login with their PostHog (or whatever product they're using)'s credentials to avoid neeeding a separate squeak account just FYI if you think about auth

  5. mariusandra commented on May 31, 2022

    @mariusandra
    CollaboratorAuthor

    I built an app that actually does something, the Notification Bar app:

    2022-05-30 15 50 09

    It uses feature flag variants for persistence. Upon loading the app, we ask the API for a list of flags, and create one with the name ${config.flagKey} if it doesn't exist. We then store the payload as the key of the only variant on that flag. Because only alphanumeric, - and _ symbols are allowed, we store the key as a base64 encoded string.

    There's a hook useFlagPersistence that abstracts away the gory bits, and provides payload, setPayload and saveChanges as functions you can call.

    To enable it on your site, you must copy/paste some code one time. There's no "auto-injection" or MITM-as-a-service like we previously discussed.

    Here's the code:

    {
        "name": "Notification Bar",
        "config": [
            {
                "key": "flagKey",
                "name": "Key of the feature flag this plugin will use",
                "type": "string",
                "hint": "Only alphanumeric with dashes and underscores",
                "default": "notification-bar",
                "required": false
            }
        ]
    }
    // frontend.tsx
    import React, { useEffect, useState } from 'react'
    import { api, lemonToast, LemonSwitch, LemonInput, LemonTextArea, LemonButton } from '@posthog/apps-common'
    
    interface FlagVariantPayload {
        message: string
        fixed: boolean
        html: boolean
        color: string
        background: string
    }
    
    const defaultKey = 'notification-bar'
    const defaultPayload: FlagVariantPayload = {
        message: '',
        color: '#fef6f6',
        background: '#920c0c',
        fixed: true,
        html: false,
    }
    
    function NotificationBar({ config }) {
        const flagKey = config.flagKey || defaultKey
        const { payload, enabled, saving, updatePayload, setEnabled, loading, saveChanges } =
            useFlagPersistence<FlagVariantPayload>(flagKey, defaultPayload, 'Managed by the "Notification Bar" app')
    
        return !loading ? (
            <div>
                <label>Message</label>
                <LemonTextArea value={payload.message} onChange={(message) => updatePayload({ message })} />
                <br />
                <label>Text color</label>
                <LemonInput label="Color" value={payload.color} onChange={(color) => updatePayload({ color })} />
                <br />
                <label>Background color</label>
                <LemonInput
                    label="Background"
                    value={payload.background}
                    onChange={(background) => updatePayload({ background })}
                />
                <br />
                <LemonSwitch label="Enabled" checked={enabled} onChange={setEnabled} />
                <LemonSwitch label="Allow HTML" checked={payload.html} onChange={(html) => updatePayload({ html })} />
                <LemonSwitch
                    label="Fixed positioning"
                    checked={payload.fixed}
                    onChange={(fixed) => updatePayload({ fixed })}
                />
                <br />
                <LemonButton type="primary" onClick={saveChanges} disabled={saving}>
                    Save
                </LemonButton>
    
                <h1 style={{ marginTop: '2rem' }}>One time setup</h1>
                <p>Copy the following code to your site. Try it now with the devtools console.</p>
                <pre>
                    {`
    posthog.onFeatureFlags((_, flags) => {
        if (!(${JSON.stringify(flagKey)} in flags)) {
            return; 
        }
        try {
            var payload = JSON.parse(atob(flags[${JSON.stringify(flagKey)}].replace(/\_/g, '=').replace(/\-/g, '+')))
            var div = document.createElement('div')
            div.style.color = payload.color || ${JSON.stringify(payload.color)}
            div.style.background = payload.background || ${JSON.stringify(payload.background)}
            div.style.padding = '5px 10px'
            if ("fixed" in payload ? payload.fixed : ${JSON.stringify(payload.fixed)}) {
                div.style.position = 'fixed'
                div.style.top = 0
                div.style.width = '100%'
                div.style.zIndex = 100000
            }
            div.style.cursor = 'pointer'
            div.addEventListener('click', () => div.remove())
            div.${payload.html ? 'innerHTML' : 'innerText'} = payload.message
            document.body.prepend(div)
        } catch (e) {
            console.error("Could not parse notification bar payload: " + message)
            throw e
        }
    })
    `.trim()}
                </pre>
            </div>
        ) : (
            <div>Loading...</div>
        )
    }
    
    export const scene = {
        title: 'Notification Bar',
        component: NotificationBar,
    }
    
    // Flag persistence
    
    interface FlagPersistenceHook<Payload> {
        payload: Payload
        setPayload: (payload: Payload) => void
        updatePayload: (payload: Partial<Payload>) => void
        enabled: boolean
        setEnabled: (enabled: boolean) => void
        loading: boolean
        saving: boolean
        saveChanges: () => Promise<void>
    }
    
    function useFlagPersistence<Payload = Record<string, any>>(
        flagKey: string,
        defaultPayload: Payload,
        flagDescription: string = 'Managed by an app'
    ): FlagPersistenceHook<Payload> {
        const [loading, setLoading] = useState(true)
        const [flags, setFlags] = useState([])
        const [flag, setFlag] = useState(null)
        const [enabled, setEnabled] = useState(false)
        const [payload, setPayload] = useState<Payload>(defaultPayload)
        const updatePayload = (values: Partial<Payload>) => setPayload((payload) => ({ ...payload, ...values }))
        const [saving, setSaving] = useState(false)
        function encode(str: string) {
            return btoa(str).replace(/\=/g, '_').replace(/\+/g, '-')
        }
        function decode(str: string) {
            return atob(str.replace(/\_/g, '=').replace(/\-/g, '+'))
        }
        function getCurrentTeamId() {
            return (window as any).POSTHOG_APP_CONTEXT.current_team.id
        }
    
        function receivedFlag(flag) {
            setFlag(flag)
            setEnabled(flag.active)
            const receivedPayload = decode(flag.filters?.multivariate?.variants?.[0]?.key ?? '')
            if (receivedPayload) {
                try {
                    setPayload({ ...defaultPayload, ...JSON.parse(receivedPayload) })
                } catch (error) {
                    console.error({ payload, error })
                    lemonToast.error(`Could not parse payload: ${payload}`)
                }
            }
        }
    
        async function saveChanges() {
            setSaving(true)
            try {
                const newFlag = await api.update(`api/projects/${getCurrentTeamId()}/feature_flags/${flag.id}`, {
                    name: flagDescription,
                    filters: {
                        groups: [
                            {
                                properties: [],
                                rollout_percentage: null,
                            },
                        ],
                        multivariate: {
                            variants: [
                                {
                                    key: encode(JSON.stringify(payload)),
                                    name: 'automatically generated base64 encoded json with substitutions "=" to "_", "+" to "-"',
                                    rollout_percentage: 100,
                                },
                            ],
                        },
                    },
                    deleted: false,
                    active: enabled,
                })
                lemonToast.success('Changes saved!')
                receivedFlag(newFlag)
            } finally {
                setSaving(false)
            }
        }
    
        useEffect(() => {
            async function fetchData() {
                try {
                    // TODO: filter by key?
                    const response = await api.get(`api/projects/${getCurrentTeamId()}/feature_flags/`)
                    const flags = response.results
                    setFlags(flags)
                    const flag = flags.find((flag) => flag.key === flagKey)
                    if (flag) {
                        receivedFlag(flag)
                    } else {
                        const newFlag = await api.create(`api/projects/${getCurrentTeamId()}/feature_flags/`, {
                            key: flagKey,
                            name: 'Managed by the "Notification Bar" app',
                            filters: {
                                groups: [
                                    {
                                        properties: [],
                                        rollout_percentage: null,
                                    },
                                ],
                                multivariate: {
                                    variants: [
                                        {
                                            key: encode(JSON.stringify(defaultPayload)),
                                            name: '',
                                            rollout_percentage: 100,
                                        },
                                    ],
                                },
                            },
                            deleted: false,
                            active: false,
                            created_by: null,
                            is_simple_flag: false,
                            rollout_percentage: null,
                        })
                        receivedFlag(newFlag)
                    }
                    setLoading(false)
                } catch (e) {
                    lemonToast.error('Error loading feature flag')
                    e.detail && lemonToast.error(e.detail)
                }
            }
            void fetchData()
        }, [])
    
        return {
            payload,
            setPayload,
            updatePayload,
            enabled,
            setEnabled,
            saving,
            loading,
            saveChanges,
        }
    }

    Potential issues:

    • This app will get bitten when the feature flags API call starts returning results in pages. A better solution is to just query for a flag with the right key via the API.
    • HTML is enabled in this case, making it possible for anyone with access to PostHog to inject anything onto your site... if you have copy/pasted this code.
    • Conversely, we could build a PostHog Tag Manager now
    • Persisting data like this is hacky. If we could get rid of the requirement that a feature flag's variant must be an alphanumeric string with dashes and underscores, and instead allow any string, we could already simplify things and save a lot of bytes.
    • We should probably expose some persistence hooks (useFlagPersistence, useAnnotationPersistence, useConfigPersistence?) to users via @posthog/apps-common. I can already see myself copy/pasting this code if I want to send other types of data to users.
    • We capture feature flag variants when running posthog.capture. This will store a lot of random metadata on each event. Should we have a secondary bus for passing data between an app and posthog-js?
      image
  6. mariusandra commented on May 31, 2022

    @mariusandra
    CollaboratorAuthor

    Here's a near-term list of TODOs for making the current level of frontend apps spark joy:

    • Add support for passing payloads via feature flags that won't get logged by posthog-js.
      • Better hack: support data urls as variants. Strip the <data> part in data:[<mediatype>][;base64],<data>
    • Make it possible to filter for a feature flag in the API by key. This might already be there.
    • Create @posthog/apps-common package
      • Create a index.d.ts file that aggregates the types of whatever is exported from src/packages/apps-common. This will be used by the plugin source editor.
      • Create a package that can be imported locally that contains the components.
    • Add support for specifying the path of frontend.tsx via plugin.json
    • Pass a canonical getCurrentTeamId() to the frontend app for API calls
    • The feedback loop between reloading and seeing changes on app is long. 10+ seconds.
      • We could implement babel directly in the source editor, and rebuild the app on the spot.
      • Instead of relying on plugin server reloads to recompile apps, we should use tasks via graphile now that there's a bridge from django.

    Medium term issues that we should tackle, felt when writing the notification bar app:

    • We need a better story for persistence.
      • Just some key/value storage that doesn't require reloading the entire plugin server on every change will go a long way... as opposed to storing things in the plugin config, which I don't really want to explicitly expose.
      • There's the postgres-based PluginStorage model that "ingestion apps" use via storage.get. Should/could we expose this to the frontend apps directly?
      • Should this posthog_pluginstorage table (id, key, value, plugin_config_id) also get team_id?
    • The "browse apps" page could use a good facelift. Various things are broken there with reordering, updates, usability, routes, etc.
    • It would be really nice to support multiple files in an app at this point. Cramming everything in frontend.tsx is limiting.
    • Instead of copy pasting, could we send and run the posthog.onFeatureFlags(...) code directly via /decide and posthog-js. This removes a manual step for the user, and allows ever more complex apps that auto-update. This also unlocks "Toolbar Apps".

    To be continued.

  7. posthog-bot commented on May 31, 2024

    @posthog-bot
    Contributor

    This issue hasn't seen activity in two years! If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in two weeks.

  8. posthog-bot commented on Jun 14, 2024

    @posthog-bot
    Contributor

    This issue was closed due to lack of activity. Feel free to reopen if it's still relevant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions