diff --git a/.dockerignore b/.dockerignore
index 5ca4b6168b2db..f5e7562caf969 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,6 +7,7 @@
!manage.py
!gunicorn.config.py
!babel.config.js
+!posthog.json
!package.json
!yarn.lock
!webpack.config.js
diff --git a/.gitignore b/.gitignore
index 2b4466b007e4c..f60b28cd25a06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ yarn-error.log
yalc.lock
cypress/screenshots/*
docker-compose.prod.yml
+posthog.json
diff --git a/dev.Dockerfile b/dev.Dockerfile
index a67b5ab0e6df5..cf1c8fdf9c4af 100644
--- a/dev.Dockerfile
+++ b/dev.Dockerfile
@@ -21,6 +21,7 @@ RUN mkdir /code/requirements/
COPY requirements/dev.txt /code/requirements/
RUN pip install -r requirements/dev.txt --compile
+COPY posthog.json /code/
COPY package.json /code/
COPY yarn.lock /code/
COPY webpack.config.js /code/
diff --git a/frontend/src/initKea.tsx b/frontend/src/initKea.tsx
index 6b322aca56a0b..cbccd6cef856c 100644
--- a/frontend/src/initKea.tsx
+++ b/frontend/src/initKea.tsx
@@ -18,7 +18,7 @@ export function initKea(): void {
Error loading "{reducerKey}".
Action "{actionKey}" responded with
-
"{error.message}"
+
"{error.message || error.detail}"
)
window['Sentry'] ? window['Sentry'].captureException(error) : console.error(error)
diff --git a/frontend/src/layout/Sidebar.js b/frontend/src/layout/Sidebar.js
index 6b62a9a6f0fc0..e2d6101859d6d 100644
--- a/frontend/src/layout/Sidebar.js
+++ b/frontend/src/layout/Sidebar.js
@@ -20,6 +20,7 @@ import {
TeamOutlined,
LockOutlined,
WalletOutlined,
+ ApiOutlined,
DatabaseOutlined,
} from '@ant-design/icons'
import { useActions, useValues } from 'kea'
@@ -64,6 +65,7 @@ const submenuOverride = {
annotations: 'settings',
billing: 'settings',
licenses: 'settings',
+ plugins: 'settings',
systemStatus: 'settings',
}
@@ -279,6 +281,14 @@ export function Sidebar({ user, sidebarCollapsed, setSidebarCollapsed }) {
)}
+
+ {user.plugin_access?.configure && (
+
+
+ Plugins
+
+
+ )}
diff --git a/frontend/src/scenes/plugins/CustomPlugin.tsx b/frontend/src/scenes/plugins/CustomPlugin.tsx
new file mode 100644
index 0000000000000..aec295bc968ed
--- /dev/null
+++ b/frontend/src/scenes/plugins/CustomPlugin.tsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import { Button, Col, Input, Row } from 'antd'
+import { useActions, useValues } from 'kea'
+import { pluginsLogic } from 'scenes/plugins/pluginsLogic'
+
+export function CustomPlugin(): JSX.Element {
+ const { customPluginUrl, pluginError, loading } = useValues(pluginsLogic)
+ const { setCustomPluginUrl, installPlugin } = useActions(pluginsLogic)
+
+ return (
+
+
Install Custom Plugin
+
+ Paste the URL of the Plugin's Github Repository to install it
+
+
+
+
+ setCustomPluginUrl(e.target.value)}
+ placeholder="https://github.com/user/repo"
+ />
+
+
+
+
+
+ {pluginError ?
{pluginError}
: null}
+
+ )
+}
diff --git a/frontend/src/scenes/plugins/InstalledPlugins.tsx b/frontend/src/scenes/plugins/InstalledPlugins.tsx
new file mode 100644
index 0000000000000..0017fc6470dba
--- /dev/null
+++ b/frontend/src/scenes/plugins/InstalledPlugins.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { Button, Col, Row, Table, Tooltip } from 'antd'
+import { useActions, useValues } from 'kea'
+import { pluginsLogic } from 'scenes/plugins/pluginsLogic'
+import { GithubOutlined, CheckOutlined, ToolOutlined, PauseOutlined } from '@ant-design/icons'
+import { PluginTypeWithConfig } from 'scenes/plugins/types'
+import { userLogic } from 'scenes/userLogic'
+
+function trimTag(tag: string): string {
+ if (tag.match(/^[a-f0-9]{40}$/)) {
+ return tag.substring(0, 7)
+ }
+ if (tag.length >= 20) {
+ return tag.substring(0, 17) + '...'
+ }
+ return tag
+}
+
+export function InstalledPlugins(): JSX.Element {
+ const { user } = useValues(userLogic)
+ const { installedPlugins, loading } = useValues(pluginsLogic)
+ const { editPlugin } = useActions(pluginsLogic)
+
+ const canInstall = user?.plugin_access?.install
+
+ return (
+
+
{canInstall ? 'Installed Plugins' : 'Plugins'}
+
plugin.name}
+ pagination={{ pageSize: 99999, hideOnSinglePage: true }}
+ dataSource={installedPlugins}
+ columns={[
+ {
+ title: 'Plugin',
+ key: 'name',
+ render: function RenderPlugin(plugin: PluginTypeWithConfig): JSX.Element {
+ return (
+ <>
+
+
+ {plugin.name}
+
+
+
+
+ {plugin.pluginConfig?.enabled ? (
+
+ {' '}
+ {plugin.pluginConfig?.global ? 'Globally Enabled' : 'Enabled'}
+
+ ) : (
+
+ )}
+
+
+ {!plugin.url?.startsWith('file:') && (
+
+ {trimTag(plugin.tag)}
+
+ )}
+
+
+ >
+ )
+ },
+ },
+ {
+ title: 'Description',
+ key: 'description',
+ render: function RenderDescription(plugin: PluginTypeWithConfig): JSX.Element {
+ return {plugin.description}
+ },
+ },
+ {
+ title: '',
+ key: 'config',
+ align: 'right',
+ render: function RenderConfig(plugin: PluginTypeWithConfig): JSX.Element | null {
+ return !plugin.pluginConfig?.global ? (
+
+ }
+ onClick={() => editPlugin(plugin.id)}
+ />
+
+ ) : null
+ },
+ },
+ ]}
+ loading={loading}
+ locale={{ emptyText: 'No Plugins Installed!' }}
+ />
+
+ )
+}
diff --git a/frontend/src/scenes/plugins/PluginModal.tsx b/frontend/src/scenes/plugins/PluginModal.tsx
new file mode 100644
index 0000000000000..713977e7e9af7
--- /dev/null
+++ b/frontend/src/scenes/plugins/PluginModal.tsx
@@ -0,0 +1,87 @@
+import React, { useEffect } from 'react'
+import { useActions, useValues } from 'kea'
+import { pluginsLogic } from 'scenes/plugins/pluginsLogic'
+import { Button, Form, Input, Modal, Popconfirm, Switch } from 'antd'
+import { DeleteOutlined } from '@ant-design/icons'
+import { userLogic } from 'scenes/userLogic'
+
+export function PluginModal(): JSX.Element {
+ const { user } = useValues(userLogic)
+ const { editingPlugin, pluginsLoading } = useValues(pluginsLogic)
+ const { editPlugin, savePluginConfig, uninstallPlugin } = useActions(pluginsLogic)
+ const [form] = Form.useForm()
+
+ const canDelete = user?.plugin_access?.install && !editingPlugin?.from_json
+
+ useEffect(() => {
+ if (editingPlugin) {
+ form.setFieldsValue({
+ ...(editingPlugin.pluginConfig.config || {}),
+ __enabled: editingPlugin.pluginConfig.enabled,
+ })
+ } else {
+ form.resetFields()
+ }
+ }, [editingPlugin?.name])
+
+ return (
+ form.submit()}
+ onCancel={() => editPlugin(null)}
+ confirmLoading={pluginsLoading}
+ footer={
+ <>
+ {canDelete && (
+ uninstallPlugin(editingPlugin.name) : () => {}}
+ okText="Yes"
+ cancelText="No"
+ >
+
+
+ )}
+
+
+ >
+ }
+ >
+
+
+ )
+}
diff --git a/frontend/src/scenes/plugins/Plugins.tsx b/frontend/src/scenes/plugins/Plugins.tsx
new file mode 100644
index 0000000000000..92f83a14efab6
--- /dev/null
+++ b/frontend/src/scenes/plugins/Plugins.tsx
@@ -0,0 +1,43 @@
+import React, { useEffect } from 'react'
+import { hot } from 'react-hot-loader/root'
+import { PluginModal } from 'scenes/plugins/PluginModal'
+import { CustomPlugin } from 'scenes/plugins/CustomPlugin'
+import { Repository } from 'scenes/plugins/Repository'
+import { InstalledPlugins } from 'scenes/plugins/InstalledPlugins'
+import { useValues } from 'kea'
+import { userLogic } from 'scenes/userLogic'
+
+export const Plugins = hot(_Plugins)
+function _Plugins(): JSX.Element {
+ const { user } = useValues(userLogic)
+
+ if (!user) {
+ return
+ }
+
+ if (!user?.plugin_access?.configure) {
+ useEffect(() => {
+ window.location.href = '/'
+ }, [])
+ return
+ }
+
+ return (
+
+
+
+ {user.plugin_access?.install ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/plugins/Repository.tsx b/frontend/src/scenes/plugins/Repository.tsx
new file mode 100644
index 0000000000000..57f13f2d9b2b0
--- /dev/null
+++ b/frontend/src/scenes/plugins/Repository.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import { Button, Table, Tooltip } from 'antd'
+import { useActions, useValues } from 'kea'
+import { pluginsLogic } from 'scenes/plugins/pluginsLogic'
+import { PluginType } from '~/types'
+import { DownloadOutlined } from '@ant-design/icons'
+import { PluginRepositoryEntry } from 'scenes/plugins/types'
+
+export function Repository(): JSX.Element {
+ const { loading, repositoryLoading, uninstalledPlugins } = useValues(pluginsLogic)
+ const { installPlugin } = useActions(pluginsLogic)
+
+ return (
+
+
Plugin Repository
+
plugin.name}
+ pagination={{ pageSize: 99999, hideOnSinglePage: true }}
+ dataSource={uninstalledPlugins}
+ columns={[
+ {
+ title: 'Plugin',
+ key: 'name',
+ render: function RenderPlugin(plugin: PluginType): JSX.Element {
+ return (
+
+ {plugin.name}
+
+ )
+ },
+ },
+ {
+ title: 'Description',
+ key: 'description',
+ render: function RenderDescription(plugin: PluginRepositoryEntry): JSX.Element {
+ return {plugin.description}
+ },
+ },
+ {
+ title: '',
+ key: 'install',
+ align: 'right',
+ render: function RenderInstall(plugin: PluginRepositoryEntry): JSX.Element {
+ return (
+
+
+ )
+ },
+ },
+ ]}
+ loading={loading || repositoryLoading}
+ locale={{ emptyText: 'All Plugins Installed!' }}
+ />
+
+ )
+}
diff --git a/frontend/src/scenes/plugins/pluginsLogic.ts b/frontend/src/scenes/plugins/pluginsLogic.ts
new file mode 100644
index 0000000000000..32089ec4d9f18
--- /dev/null
+++ b/frontend/src/scenes/plugins/pluginsLogic.ts
@@ -0,0 +1,248 @@
+import { kea } from 'kea'
+import { pluginsLogicType } from 'types/scenes/plugins/pluginsLogicType'
+import api from 'lib/api'
+import { PluginConfigType, PluginType } from '~/types'
+import { PluginRepositoryEntry, PluginTypeWithConfig } from './types'
+
+export const pluginsLogic = kea<
+ pluginsLogicType
+>({
+ actions: {
+ editPlugin: (id: number | null) => ({ id }),
+ savePluginConfig: (pluginConfigChanges: Record) => ({ pluginConfigChanges }),
+ installPlugin: (pluginUrl: string, isCustom: boolean = false) => ({ pluginUrl, isCustom }),
+ uninstallPlugin: (name: string) => ({ name }),
+ setCustomPluginUrl: (customPluginUrl: string) => ({ customPluginUrl }),
+ },
+
+ loaders: ({ values }) => ({
+ plugins: [
+ {} as Record,
+ {
+ loadPlugins: async () => {
+ const { results } = await api.get('api/plugin')
+ const plugins: Record = {}
+ for (const plugin of results as PluginType[]) {
+ plugins[plugin.id] = plugin
+ }
+ return plugins
+ },
+ installPlugin: async ({ pluginUrl }) => {
+ const { plugins } = values
+
+ const match = pluginUrl.match(/https?:\/\/(www\.|)github.com\/([^\/]+)\/([^\/]+)\/?$/)
+ if (!match) {
+ throw new Error('Must be in the format: https://github.com/user/repo')
+ }
+ const [, , user, repo] = match
+
+ const repoCommitsUrl = `https://api.github.com/repos/${user}/${repo}/commits`
+ const repoCommits: Record[] | null = await window
+ .fetch(repoCommitsUrl)
+ .then((response) => response?.json())
+ .catch(() => null)
+
+ if (!repoCommits || repoCommits.length === 0) {
+ throw new Error(`Could not find repository: ${pluginUrl}`)
+ }
+
+ const tag: string = repoCommits[0].sha
+ const jsonUrl = `https://raw.githubusercontent.com/${user}/${repo}/${tag}/plugin.json`
+ const json: PluginRepositoryEntry | null = await window
+ .fetch(jsonUrl)
+ .then((response) => response?.json())
+ .catch(() => null)
+
+ if (!json) {
+ throw new Error(`Could not find plugin.json in repository: ${pluginUrl}`)
+ }
+
+ if (Object.values(values.plugins).find((p) => p.name === json.name)) {
+ throw new Error(`Plugin with the name "${json.name}" already installed!`)
+ }
+
+ const response = await api.create('api/plugin', {
+ name: json.name,
+ description: json.description,
+ url: json.url,
+ tag,
+ config_schema: json.config,
+ })
+
+ return { ...plugins, [response.id]: response }
+ },
+ uninstallPlugin: async () => {
+ const { plugins, editingPlugin } = values
+ if (!editingPlugin) {
+ return plugins
+ }
+
+ await api.delete(`api/plugin/${editingPlugin.id}`)
+ const { [editingPlugin.id]: _discard, ...rest } = plugins // eslint-disable-line
+ return rest
+ },
+ },
+ ],
+ pluginConfigs: [
+ {} as Record,
+ {
+ loadPluginConfigs: async () => {
+ const pluginConfigs: Record = {}
+
+ const [{ results }, globalResults] = await Promise.all([
+ api.get('api/plugin_config'),
+ api.get('api/plugin_config/global_plugins/'),
+ ])
+
+ for (const pluginConfig of results as PluginConfigType[]) {
+ pluginConfigs[pluginConfig.plugin] = { ...pluginConfig, global: false }
+ }
+ for (const pluginConfig of globalResults as PluginConfigType[]) {
+ pluginConfigs[pluginConfig.plugin] = { ...pluginConfig, global: true }
+ }
+
+ return pluginConfigs
+ },
+ savePluginConfig: async ({ pluginConfigChanges }) => {
+ const { pluginConfigs, editingPlugin } = values
+
+ if (!editingPlugin) {
+ return pluginConfigs
+ }
+
+ const { __enabled: enabled, ...config } = pluginConfigChanges
+
+ let response
+ if (editingPlugin.pluginConfig.id) {
+ response = await api.update(`api/plugin_config/${editingPlugin.pluginConfig.id}`, {
+ enabled,
+ config,
+ })
+ } else {
+ response = await api.create(`api/plugin_config/`, {
+ plugin: editingPlugin.id,
+ enabled,
+ config,
+ order: 0,
+ })
+ }
+
+ return { ...pluginConfigs, [response.plugin]: response }
+ },
+ },
+ ],
+ repository: [
+ {} as Record,
+ {
+ loadRepository: async () => {
+ const results = await api.get('api/plugin/repository')
+ const repository: Record = {}
+ for (const plugin of results as PluginRepositoryEntry[]) {
+ repository[plugin.name] = plugin
+ }
+ return repository
+ },
+ },
+ ],
+ }),
+
+ reducers: {
+ editingPluginId: [
+ null as number | null,
+ {
+ editPlugin: (_, { id }) => id,
+ savePluginConfigSuccess: () => null,
+ uninstallPluginSuccess: () => null,
+ installPluginSuccess: (_, { plugins }) => Object.values(plugins).pop()?.id || null,
+ },
+ ],
+ customPluginUrl: [
+ '',
+ {
+ setCustomPluginUrl: (_, { customPluginUrl }) => customPluginUrl,
+ installPluginSuccess: () => '',
+ },
+ ],
+ pluginError: [
+ null as null | string,
+ {
+ setCustomPluginUrl: () => null,
+ installPlugin: () => null,
+ installPluginFailure: (_, { error }) => error || '',
+ },
+ ],
+ pluginConfigs: {
+ uninstallPluginSuccess: (pluginConfigs, { plugins }) => {
+ const newPluginConfigs: Record = {}
+ Object.values(pluginConfigs).forEach((pluginConfig) => {
+ if (plugins[pluginConfig.plugin]) {
+ newPluginConfigs[pluginConfig.plugin] = pluginConfig
+ }
+ })
+ return newPluginConfigs
+ },
+ },
+ },
+
+ selectors: {
+ installedPlugins: [
+ (s) => [s.plugins, s.pluginConfigs],
+ (plugins, pluginConfigs): PluginTypeWithConfig[] => {
+ const pluginValues = Object.values(plugins)
+ return pluginValues
+ .map((plugin, index) => {
+ let pluginConfig = pluginConfigs[plugin.id]
+ if (!pluginConfig) {
+ const config: Record = {}
+ Object.entries(plugin.config_schema).forEach(([key, { default: def }]) => {
+ config[key] = def
+ })
+
+ pluginConfig = {
+ id: undefined,
+ plugin: plugin.id,
+ enabled: false,
+ config: config,
+ order: pluginValues.length + index,
+ }
+ }
+ return { ...plugin, pluginConfig }
+ })
+ .sort((a, b) => a.pluginConfig?.order - b.pluginConfig?.order)
+ .map((plugin, index) => ({ ...plugin, order: index + 1 }))
+ },
+ ],
+ installedPluginNames: [
+ (s) => [s.installedPlugins],
+ (installedPlugins) => {
+ const names: Record = {}
+ installedPlugins.forEach((plugin) => {
+ names[plugin.name] = true
+ })
+ return names
+ },
+ ],
+ uninstalledPlugins: [
+ (s) => [s.installedPluginNames, s.repository],
+ (installedPluginNames, repository) => {
+ return Object.keys(repository)
+ .filter((name) => !installedPluginNames[name])
+ .map((name) => repository[name])
+ },
+ ],
+ editingPlugin: [
+ (s) => [s.editingPluginId, s.installedPlugins],
+ (editingPluginId, installedPlugins) =>
+ editingPluginId ? installedPlugins.find((plugin) => plugin.id === editingPluginId) : null,
+ ],
+ loading: [
+ (s) => [s.pluginsLoading, s.repositoryLoading, s.pluginConfigsLoading],
+ (pluginsLoading, repositoryLoading, pluginConfigsLoading) =>
+ pluginsLoading || repositoryLoading || pluginConfigsLoading,
+ ],
+ },
+
+ events: ({ actions }) => ({
+ afterMount: [actions.loadPlugins, actions.loadPluginConfigs, actions.loadRepository],
+ }),
+})
diff --git a/frontend/src/scenes/plugins/types.ts b/frontend/src/scenes/plugins/types.ts
new file mode 100644
index 0000000000000..99bca8be60510
--- /dev/null
+++ b/frontend/src/scenes/plugins/types.ts
@@ -0,0 +1,13 @@
+import { PluginConfigType, PluginType } from '~/types'
+
+export interface PluginRepositoryEntry {
+ name: string
+ url: string
+ description: string
+ tag: string
+ config?: Record
+}
+
+export interface PluginTypeWithConfig extends PluginType {
+ pluginConfig: PluginConfigType
+}
diff --git a/frontend/src/scenes/sceneLogic.js b/frontend/src/scenes/sceneLogic.js
index 7766cb4233727..f39b70a64c7d2 100644
--- a/frontend/src/scenes/sceneLogic.js
+++ b/frontend/src/scenes/sceneLogic.js
@@ -28,6 +28,7 @@ export const scenes = {
signup: () => import(/* webpackChunkName: 'signup' */ './team/Signup'),
ingestion: () => import(/* webpackChunkName: 'ingestion' */ './ingestion/IngestionWizard'),
billing: () => import(/* webpackChunkName: 'billing' */ './billing/Billing'),
+ plugins: () => import(/* webpackChunkName: 'plugins' */ './plugins/Plugins'),
}
/* List of routes that do not require authentication (N.B. add to posthog.urls too) */
@@ -57,6 +58,7 @@ export const routes = {
'/annotations': 'annotations',
'/team': 'team',
'/setup/licenses': 'licenses',
+ '/setup/plugins': 'plugins',
'/system_status': 'systemStatus',
'/preflight': 'preflight',
'/signup': 'signup',
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c2408276d4e32..6a6715bf09c58 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -11,12 +11,19 @@ export interface UserType {
team: TeamType
toolbar_mode: 'disabled' | 'toolbar'
billing: OrganizationBilling
+ plugin_access: PluginAccess
}
export interface UserUpdateType extends Omit, 'team'> {
team: Partial
}
+export interface PluginAccess {
+ view: boolean
+ install: boolean
+ configure: boolean
+}
+
export interface PersonalAPIKeyType {
id: string
label: string
@@ -184,3 +191,36 @@ export interface DashboardType {
share_token: string
deleted: boolean
}
+
+export interface PluginConfigSchema {
+ name: string
+ type: string
+ default: any
+ required: boolean
+}
+
+export interface PluginType {
+ id: number
+ name: string
+ description: string
+ url: string
+ tag: string
+ config_schema: Record
+ from_json: boolean
+ from_web: boolean
+ error?: {
+ message: string
+ }
+}
+
+export interface PluginConfigType {
+ id?: number
+ plugin: number
+ enabled: boolean
+ order: number
+ config: Record
+ global?: boolean
+ error?: {
+ message: string
+ }
+}
diff --git a/latest_migrations.manifest b/latest_migrations.manifest
index b98c1942e9561..f505d1b14c68c 100644
--- a/latest_migrations.manifest
+++ b/latest_migrations.manifest
@@ -2,7 +2,7 @@ admin: 0003_logentry_add_action_flag_choices
auth: 0011_update_proxy_permissions
contenttypes: 0002_remove_content_type_name
ee: 0002_hook
-posthog: 0089_auto_20201015_1031
+posthog: 0090_plugins
rest_hooks: 0002_swappable_hook_model
sessions: 0001_initial
social_django: 0008_partial_timestamp
diff --git a/package.json b/package.json
index 1b53160e320a3..bc739835e4706 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,6 @@
"d3": "^5.15.0",
"d3-sankey": "^0.12.3",
"editor": "^1.0.0",
- "eslint-plugin-cypress": "^2.11.1",
"expr-eval": "^2.0.2",
"funnel-graph-js": "^1.4.1",
"fuse.js": "^6.4.1",
@@ -92,13 +91,14 @@
"cypress-terminal-report": "^2.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
+ "eslint-plugin-cypress": "^2.11.2",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.20.3",
"file-loader": "^6.1.0",
"html-webpack-harddisk-plugin": "^1.0.1",
"html-webpack-plugin": "^4.4.1",
"husky": "~4.2.5",
- "kea-typegen": "^0.3.0",
+ "kea-typegen": "^0.3.2",
"lint-staged": "~10.2.13",
"mini-css-extract-plugin": "^0.11.0",
"nodemon": "^2.0.4",
diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py
index 552e022cfa732..d6986739dd8fa 100644
--- a/posthog/api/__init__.py
+++ b/posthog/api/__init__.py
@@ -19,6 +19,7 @@
paths,
person,
personal_api_key,
+ plugin,
team_user,
)
@@ -44,6 +45,8 @@ def api_not_found(request):
router.register(r"dashboard", dashboard.DashboardsViewSet)
router.register(r"dashboard_item", dashboard.DashboardItemsViewSet)
router.register(r"cohort", cohort.CohortViewSet)
+router.register(r"plugin", plugin.PluginViewSet)
+router.register(r"plugin_config", plugin.PluginConfigViewSet)
router.register(r"personal_api_keys", personal_api_key.PersonalAPIKeyViewSet, basename="personal_api_keys")
router.register(r"team/user", team_user.TeamUserViewSet)
diff --git a/posthog/api/plugin.py b/posthog/api/plugin.py
new file mode 100644
index 0000000000000..da0dac2a60a50
--- /dev/null
+++ b/posthog/api/plugin.py
@@ -0,0 +1,132 @@
+import json
+from typing import Any, Dict
+
+import requests
+from django.conf import settings
+from rest_framework import request, serializers, viewsets
+from rest_framework.decorators import action
+from rest_framework.exceptions import APIException
+from rest_framework.response import Response
+
+from posthog.models import Plugin, PluginConfig
+from posthog.plugins import download_plugin_github_zip, reload_plugins_on_workers
+
+
+class PluginSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Plugin
+ fields = ["id", "name", "description", "url", "config_schema", "tag", "from_json"]
+
+ def create(self, validated_data: Dict, *args: Any, **kwargs: Any) -> Plugin:
+ if not settings.INSTALL_PLUGINS_FROM_WEB:
+ raise APIException("Plugin installation via the web is disabled!")
+ if len(Plugin.objects.filter(name=validated_data["name"])) > 0:
+ raise APIException('Plugin with name "{}" already installed!'.format(validated_data["name"]))
+ validated_data["archive"] = download_plugin_github_zip(validated_data["url"], validated_data["tag"])
+ if "from_json" in validated_data: # prevent hackery
+ del validated_data["from_json"]
+ plugin = Plugin.objects.create(from_web=True, **validated_data)
+ reload_plugins_on_workers()
+ return plugin
+
+ def update(self, plugin: Plugin, validated_data: Dict, *args: Any, **kwargs: Any) -> Plugin: # type: ignore
+ if not settings.INSTALL_PLUGINS_FROM_WEB:
+ raise APIException("Plugin installation via the web is disabled!")
+ if plugin.from_json:
+ raise APIException('Can not update plugin "{}", which is configured from posthog.json!'.format(plugin.name))
+ plugin.name = validated_data.get("name", plugin.name)
+ plugin.description = validated_data.get("description", plugin.description)
+ plugin.url = validated_data.get("url", plugin.url)
+ plugin.config_schema = validated_data.get("config_schema", plugin.config_schema)
+ plugin.tag = validated_data.get("tag", plugin.tag)
+ plugin.archive = download_plugin_github_zip(plugin.url, plugin.tag)
+ plugin.save()
+ reload_plugins_on_workers()
+ return plugin
+
+
+class PluginViewSet(viewsets.ModelViewSet):
+ queryset = Plugin.objects.all()
+ serializer_class = PluginSerializer
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+ if not settings.INSTALL_PLUGINS_FROM_WEB and not settings.CONFIGURE_PLUGINS_FROM_WEB:
+ return queryset.none()
+ return queryset
+
+ @action(methods=["GET"], detail=False)
+ def repository(self, request: request.Request):
+ if not settings.INSTALL_PLUGINS_FROM_WEB:
+ return Response([])
+ url = "https://raw.githubusercontent.com/PostHog/plugins/main/repository.json"
+ plugins = requests.get(url)
+ return Response(json.loads(plugins.text))
+
+ def destroy(self, request: request.Request, pk=None) -> Response: # type: ignore
+ if not settings.INSTALL_PLUGINS_FROM_WEB:
+ raise APIException("Plugin installation via the web is disabled!")
+ plugin = Plugin.objects.get(pk=pk)
+ if plugin.from_json:
+ raise APIException('Can not delete plugin "{}", which is configured from posthog.json!'.format(plugin.name))
+ plugin.delete()
+ reload_plugins_on_workers()
+ return Response(status=204)
+
+
+class PluginConfigSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = PluginConfig
+ fields = ["id", "plugin", "enabled", "order", "config"]
+
+ def create(self, validated_data: Dict, *args: Any, **kwargs: Any) -> PluginConfig:
+ if not settings.CONFIGURE_PLUGINS_FROM_WEB:
+ raise APIException("Plugin configuration via the web is disabled!")
+ request = self.context["request"]
+ plugin_config = PluginConfig.objects.create(team=request.user.team, **validated_data)
+ reload_plugins_on_workers()
+ return plugin_config
+
+ def update(self, plugin_config: PluginConfig, validated_data: Dict, *args: Any, **kwargs: Any) -> PluginConfig: # type: ignore
+ if not settings.CONFIGURE_PLUGINS_FROM_WEB:
+ raise APIException("Plugin configuration via the web is disabled!")
+ plugin_config.enabled = validated_data.get("enabled", plugin_config.enabled)
+ plugin_config.config = validated_data.get("config", plugin_config.config)
+ plugin_config.order = validated_data.get("order", plugin_config.order)
+ plugin_config.save()
+ reload_plugins_on_workers()
+ return plugin_config
+
+
+class PluginConfigViewSet(viewsets.ModelViewSet):
+ queryset = PluginConfig.objects.all()
+ serializer_class = PluginConfigSerializer
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+ if not settings.CONFIGURE_PLUGINS_FROM_WEB:
+ return queryset.none()
+ return queryset.filter(team_id=self.request.user.team.pk)
+
+ # we don't use this endpoint, but have something anyway to prevent team leakage
+ def destroy(self, request: request.Request, pk=None) -> Response: # type: ignore
+ if not settings.CONFIGURE_PLUGINS_FROM_WEB:
+ raise APIException("Plugin configuration via the web is disabled!")
+ plugin_config = PluginConfig.objects.get(team=request.user.team, pk=pk)
+ plugin_config.enabled = False
+ plugin_config.save()
+ return Response(status=204)
+
+ @action(methods=["GET"], detail=False)
+ def global_plugins(self, request: request.Request):
+ if not settings.INSTALL_PLUGINS_FROM_WEB:
+ return Response([])
+
+ response = []
+ plugin_configs = PluginConfig.objects.filter(team_id=None, enabled=True) # type: ignore
+ for plugin_config in plugin_configs:
+ plugin = PluginConfigSerializer(plugin_config).data
+ plugin["config"] = None
+ response.append(plugin)
+
+ return Response(response)
diff --git a/posthog/api/test/base.py b/posthog/api/test/base.py
index 3dcbb110e76e1..6d82465837cd5 100644
--- a/posthog/api/test/base.py
+++ b/posthog/api/test/base.py
@@ -5,6 +5,7 @@
from posthog.cache import clear_cache
from posthog.models import Organization, Team, User
+from posthog.plugins import Plugins
class TestMixin:
@@ -29,6 +30,7 @@ def setUp(self):
self.client = Client()
if self.TESTS_FORCE_LOGIN and self.TESTS_EMAIL:
self.client.force_login(self.user)
+ Plugins().reload_plugins()
class ErrorResponsesMixin:
diff --git a/posthog/api/user.py b/posthog/api/user.py
index 3782f4ba452b0..dd579d6c0c36a 100644
--- a/posthog/api/user.py
+++ b/posthog/api/user.py
@@ -93,6 +93,10 @@ def user(request):
"billing_plan": request.user.billing_plan,
"is_multi_tenancy": getattr(settings, "MULTI_TENANCY", False),
"ee_available": request.user.ee_available,
+ "plugin_access": {
+ "install": settings.INSTALL_PLUGINS_FROM_WEB,
+ "configure": settings.CONFIGURE_PLUGINS_FROM_WEB,
+ },
}
)
diff --git a/posthog/apps.py b/posthog/apps.py
index cb945bc7b5af6..20d19465b247b 100644
--- a/posthog/apps.py
+++ b/posthog/apps.py
@@ -1,4 +1,5 @@
import os
+import sys
import posthoganalytics
from django.apps import AppConfig
@@ -15,6 +16,7 @@ class PostHogConfig(AppConfig):
def ready(self):
posthoganalytics.api_key = "sTMFPsFhdP1Ssg"
posthoganalytics.personal_api_key = os.environ.get("POSTHOG_PERSONAL_API_KEY")
+
if settings.DEBUG:
# log development server launch to posthog
if os.getenv("RUN_MAIN") == "true":
diff --git a/posthog/cache.py b/posthog/cache.py
index e22ae0b96cec1..8143aef24eb55 100644
--- a/posthog/cache.py
+++ b/posthog/cache.py
@@ -6,10 +6,22 @@
redis_instance = None # type: Optional[redis.Redis]
-if settings.TEST:
- redis_instance = fakeredis.FakeStrictRedis()
-elif settings.REDIS_URL:
- redis_instance = redis.from_url(settings.REDIS_URL, db=0)
+
+def get_redis_instance() -> redis.Redis:
+ global redis_instance
+
+ if redis_instance:
+ return redis_instance
+
+ if settings.TEST:
+ redis_instance = fakeredis.FakeStrictRedis()
+ elif settings.REDIS_URL:
+ redis_instance = redis.from_url(settings.REDIS_URL, db=0)
+
+ if not redis_instance:
+ raise Exception("Redis not configured!")
+
+ return redis_instance
def get_cache_key(team_id: int, key: str) -> str:
@@ -17,21 +29,14 @@ def get_cache_key(team_id: int, key: str) -> str:
def get_cached_value(team_id: int, key: str) -> Optional[str]:
- if not redis_instance:
- raise Exception("Redis not configured!")
- return redis_instance.get(get_cache_key(team_id, key))
+ return get_redis_instance().get(get_cache_key(team_id, key))
def set_cached_value(team_id: int, key: str, value: str) -> None:
- if not redis_instance:
- raise Exception("Redis not configured!")
- redis_instance.set(get_cache_key(team_id, key), value)
+ get_redis_instance().set(get_cache_key(team_id, key), value)
def clear_cache() -> None:
if not settings.TEST and not settings.DEBUG:
raise Exception("Can only clear redis cache in TEST or DEBUG mode!")
- if not redis_instance:
- raise Exception("Redis not configured!")
-
- redis_instance.flushdb()
+ get_redis_instance().flushdb()
diff --git a/posthog/celery.py b/posthog/celery.py
index 395c107595c0c..227b8dc02bc11 100644
--- a/posthog/celery.py
+++ b/posthog/celery.py
@@ -1,13 +1,15 @@
import os
import time
-import redis
import statsd # type: ignore
from celery import Celery
from celery.schedules import crontab
+from celery.signals import task_prerun, worker_process_init
from django.conf import settings
from django.db import connection
+from posthog.cache import get_redis_instance
+
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "posthog.settings")
@@ -26,15 +28,26 @@
# https://stackoverflow.com/questions/47106592/redis-connections-not-being-released-after-celery-task-is-complete
app.conf.broker_pool_limit = 0
-# Connect to our Redis instance to store the heartbeat
-redis_instance = redis.from_url(settings.REDIS_URL, db=0)
-
# How frequently do we want to calculate action -> event relationships if async is enabled
ACTION_EVENT_MAPPING_INTERVAL_MINUTES = 10
statsd.Connection.set_defaults(host=settings.STATSD_HOST, port=settings.STATSD_PORT)
+@worker_process_init.connect
+def reload_plugins_if_needed(**kwargs):
+ from posthog.plugins import Plugins
+
+ Plugins()
+
+
+@task_prerun.connect
+def reload_plugins_if_needed(**kwargs):
+ from posthog.plugins import Plugins
+
+ Plugins().check_reload_plugins_periodically(seconds=10)
+
+
@app.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
if not settings.DEBUG:
@@ -69,14 +82,14 @@ def setup_periodic_tasks(sender, **kwargs):
@app.task
def redis_heartbeat():
- redis_instance.set("POSTHOG_HEARTBEAT", int(time.time()))
+ get_redis_instance().set("POSTHOG_HEARTBEAT", int(time.time()))
@app.task
def redis_celery_queue_depth():
try:
g = statsd.Gauge("%s_posthog_celery" % (settings.STATSD_PREFIX,))
- llen = redis_instance.llen("celery")
+ llen = get_redis_instance().llen("celery")
g.send("queue_depth", llen)
except:
# if we can't connect to statsd don't complain about it.
diff --git a/posthog/migrations/0090_plugins.py b/posthog/migrations/0090_plugins.py
new file mode 100644
index 0000000000000..309c1d5621cc6
--- /dev/null
+++ b/posthog/migrations/0090_plugins.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.0.7 on 2020-10-08 15:24
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("posthog", "0089_auto_20201015_1031"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Plugin",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(blank=True, max_length=200, null=True)),
+ ("description", models.TextField(blank=True, null=True)),
+ ("url", models.CharField(blank=True, max_length=800, null=True)),
+ ("config_schema", django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ ("tag", models.CharField(blank=True, max_length=200, null=True)),
+ ("archive", models.BinaryField(blank=True, null=True)),
+ ("from_json", models.BooleanField(default=False)),
+ ("from_web", models.BooleanField(default=False)),
+ ("error", django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="PluginConfig",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.Team", null=True)),
+ ("plugin", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.Plugin")),
+ ("enabled", models.BooleanField(default=False)),
+ ("order", models.IntegerField(blank=True, null=True)),
+ ("config", django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+ ("error", django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True)),
+ ],
+ ),
+ ]
diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py
index fa3619a57cd1f..5b64d0f6985ba 100644
--- a/posthog/models/__init__.py
+++ b/posthog/models/__init__.py
@@ -14,6 +14,7 @@
from .organization import Organization, OrganizationInvite, OrganizationMembership
from .person import Person, PersonDistinctId
from .personal_api_key import PersonalAPIKey
+from .plugin import Plugin, PluginConfig
from .property import Property
from .team import Team
from .user import User, UserManager
diff --git a/posthog/models/plugin.py b/posthog/models/plugin.py
new file mode 100644
index 0000000000000..645c89693d3cd
--- /dev/null
+++ b/posthog/models/plugin.py
@@ -0,0 +1,23 @@
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+
+
+class Plugin(models.Model):
+ name: models.CharField = models.CharField(max_length=200, null=True, blank=True)
+ description: models.TextField = models.TextField(null=True, blank=True)
+ url: models.CharField = models.CharField(max_length=800, null=True, blank=True)
+ config_schema: JSONField = JSONField(default=dict)
+ tag: models.CharField = models.CharField(max_length=200, null=True, blank=True)
+ archive: models.BinaryField = models.BinaryField(blank=True, null=True)
+ from_json: models.BooleanField = models.BooleanField(default=False)
+ from_web: models.BooleanField = models.BooleanField(default=False)
+ error: JSONField = JSONField(default=None, null=True)
+
+
+class PluginConfig(models.Model):
+ team: models.ForeignKey = models.ForeignKey("Team", on_delete=models.CASCADE, null=True)
+ plugin: models.ForeignKey = models.ForeignKey("Plugin", on_delete=models.CASCADE)
+ enabled: models.BooleanField = models.BooleanField(default=False)
+ order: models.IntegerField = models.IntegerField(null=True, blank=True)
+ config: JSONField = JSONField(default=dict)
+ error: JSONField = JSONField(default=None, null=True)
diff --git a/posthog/plugins/__init__.py b/posthog/plugins/__init__.py
new file mode 100644
index 0000000000000..f0d971d788051
--- /dev/null
+++ b/posthog/plugins/__init__.py
@@ -0,0 +1,5 @@
+from .cache import PluginCache
+from .models import PluginBaseClass, PluginModule, PosthogEvent, TeamPlugin
+from .plugins import Plugins, reload_plugins_on_workers
+from .sync import sync_posthog_json_plugins
+from .utils import download_plugin_github_zip
diff --git a/posthog/plugins/cache.py b/posthog/plugins/cache.py
new file mode 100644
index 0000000000000..6889ddb76623d
--- /dev/null
+++ b/posthog/plugins/cache.py
@@ -0,0 +1,31 @@
+import pickle
+from typing import Any
+
+from posthog.cache import get_redis_instance
+
+
+class PluginCache:
+ def __init__(self, scope: str):
+ self.scope = scope
+ self.redis = get_redis_instance()
+
+ def format_key(self, key):
+ key = "{scope}_{key}".format(scope=self.scope, key=key)
+ return key
+
+ def set(self, key: str, value: Any):
+ if not self.redis:
+ raise Exception("Redis not configured!")
+ key = self.format_key(key)
+ value = pickle.dumps(value)
+ self.redis.set(key, value)
+
+ def get(self, key) -> Any:
+ if not self.redis:
+ raise Exception("Redis not configured!")
+ key = self.format_key(key)
+ str_value = self.redis.get(key)
+ if not str_value:
+ return None
+ value = pickle.loads(str_value)
+ return value
diff --git a/posthog/plugins/models.py b/posthog/plugins/models.py
new file mode 100644
index 0000000000000..64e419d79f9ae
--- /dev/null
+++ b/posthog/plugins/models.py
@@ -0,0 +1,79 @@
+import datetime
+from dataclasses import dataclass
+from types import ModuleType
+from typing import Any, Dict, List, Optional, Type, Union
+
+from .cache import PluginCache
+
+
+@dataclass
+class PosthogEvent:
+ ip: str
+ site_url: str
+ event: str
+ distinct_id: str
+ team_id: int
+ properties: Dict[Any, Any]
+ timestamp: datetime.datetime
+
+
+# Contains metadata and the python module for a plugin
+@dataclass
+class PluginModule:
+ id: int # id in the Plugin model
+ name: str # name in the Plugin model
+ url: str # url in the Plugin model, can be https: or file:
+ tag: str # tag in the Plugin model
+ module_name: str # name of the module, "posthog.plugins.plugin_{id}_{name}_{tag}"
+ plugin_path: str # path of the local folder or the temporary .zip file for github
+ requirements: List[str] # requirements.txt split into lines
+ module: ModuleType # python module
+ plugin: Type["PluginBaseClass"] # plugin base class extracted from the exports in the module
+
+
+# Contains per-team config for a plugin
+@dataclass
+class TeamPlugin:
+ team: Union[int, None] # team id
+ plugin: int # plugin id
+ order: int # plugin order
+ name: str # plugin name
+ tag: str # plugin tag
+ config: Dict[str, Any] # config from the DB
+ loaded_class: Optional["PluginBaseClass"] # link to the class
+ plugin_module: PluginModule # link to the module
+
+
+class PluginBaseClass:
+ def __init__(self, config: TeamPlugin):
+ self.config = config.config
+ self.team = config.team
+ self.cache = PluginCache(scope="{}/{}".format(config.name, config.team))
+ self.team_init()
+
+ # Called once per instance when the plugin is loaded before the class is intialized
+ @staticmethod
+ def instance_init():
+ pass
+
+ # Called after the __init__ per team
+ def team_init(self):
+ pass
+
+ # Called before any event is processed
+ def process_event(self, event: PosthogEvent):
+ return event
+
+ # Called before any alias event is processed
+ def process_alias(self, event: PosthogEvent):
+ return event
+
+ # Called before any identify is processed
+ def process_identify(self, event: PosthogEvent):
+ return event
+
+
+class PluginError(Exception):
+ def __init__(self, message="Error"):
+ self.message = message
+ super().__init__(self.message)
diff --git a/posthog/plugins/plugins.py b/posthog/plugins/plugins.py
new file mode 100644
index 0000000000000..db46f321de21f
--- /dev/null
+++ b/posthog/plugins/plugins.py
@@ -0,0 +1,332 @@
+import importlib
+import importlib.util
+import inspect
+import os
+import tempfile
+import zipimport
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional, Union
+from zipfile import ZipFile
+
+from django.db.models import F
+
+from posthog.cache import get_redis_instance
+from posthog.models.plugin import Plugin, PluginConfig
+from posthog.utils import SingletonDecorator
+
+from .models import PluginBaseClass, PluginError, PluginModule, PosthogEvent, TeamPlugin
+from .sync import sync_global_plugin_config, sync_posthog_json_plugins
+
+REDIS_INSTANCE = get_redis_instance()
+
+
+def reload_plugins_on_workers():
+ get_redis_instance().incr("@posthog/plugin-reload", 1)
+
+
+class _Plugins:
+ def __init__(self):
+ self.plugins: List[Plugin] = [] # type not loaded yet
+ self.plugin_configs: List[PluginConfig] = [] # type not loaded yet
+ self.plugins_by_id: Dict[int, PluginModule] = {}
+ self.plugins_by_team: Dict[Union[int, None], List[TeamPlugin]] = {}
+
+ sync_posthog_json_plugins()
+ sync_global_plugin_config()
+
+ self.plugin_counter = self.get_plugin_counter()
+ self.last_plugins_check = datetime.now()
+
+ self.load_plugins()
+ self.load_plugin_configs()
+
+ def load_plugins(self):
+ self.plugins = list(Plugin.objects.all())
+
+ # unregister plugins no longer in use
+ active_plugin_ids = {}
+ for plugin in self.plugins:
+ active_plugin_ids[plugin.id] = True
+ plugin_ids = list(self.plugins_by_id.keys())
+ for plugin_id in plugin_ids:
+ if not active_plugin_ids.get(plugin_id, None):
+ self.unregister_plugin(plugin_id)
+
+ # register plugins not yet seen
+ for plugin in self.plugins:
+ local_plugin = plugin.url.startswith("file:") and not plugin.archive
+ old_plugin = self.plugins_by_id.get(plugin.id, None)
+ requirements = []
+
+ if old_plugin:
+ # skip reloading if same tag already loaded
+ if old_plugin.url == plugin.url and old_plugin.tag == plugin.tag and not local_plugin:
+ continue
+ self.unregister_plugin(plugin.id)
+
+ if not plugin.archive and not local_plugin:
+ self.register_error(plugin, PluginError('Archive not downloaded and it\'s not a local "file:" plugin'))
+ continue
+
+ if local_plugin:
+ module_name = "posthog.plugins.plugin_{id}_{name}".format(id=plugin.id, name=plugin.name)
+ plugin_path = os.path.realpath(plugin.url.replace("file:", "", 1))
+
+ try:
+ requirements_path = os.path.join(plugin_path, "requirements.txt")
+ requirements_file = open(requirements_path, "r")
+ requirements = requirements_file.read().split("\n")
+ requirements = [x for x in requirements if x]
+ requirements_file.close()
+ self.install_requirements(plugin.name, requirements)
+ except FileNotFoundError:
+ pass
+
+ spec = importlib.util.spec_from_file_location(module_name, os.path.join(plugin_path, "__init__.py"))
+ if spec:
+ try:
+ module = importlib.util.module_from_spec(spec)
+ if module:
+ spec.loader.exec_module(module) # type: ignore
+ else:
+ self.register_error(plugin, PluginError("Could not find module in __init__.py"))
+ continue
+ except Exception as e:
+ self.register_error(plugin, PluginError("Error initializing __init__.py"), e)
+ continue
+ else:
+ self.register_error(plugin, PluginError("Could not find module in __init__.py"))
+ continue
+
+ else:
+ module_name = "{}-{}".format(plugin.name, plugin.tag)
+ fd, plugin_path = tempfile.mkstemp(prefix=plugin.name + "-", suffix=".zip")
+ os.write(fd, plugin.archive)
+ os.close(fd)
+
+ zip_file = ZipFile(plugin_path)
+ zip_root_folder = zip_file.namelist()[0]
+
+ try:
+ requirements_path = os.path.join(zip_root_folder, "requirements.txt")
+ with zip_file.open(requirements_path) as requirements_zip_file:
+ requirements = requirements_zip_file.read().decode("utf-8").split("\n")
+ requirements = [x for x in requirements if x]
+ self.install_requirements(plugin.name, requirements)
+ except KeyError:
+ pass # no requirements.txt found
+ except PluginError as e:
+ self.register_error(plugin, e)
+ continue
+
+ try:
+ importer = zipimport.zipimporter(plugin_path)
+ module = importer.load_module(module_name)
+ except zipimport.ZipImportError as e:
+ self.register_error(
+ plugin, PluginError("Could not find __init__.py from the plugin zip archive"), e
+ )
+ os.unlink(plugin_path) # temporary file no longer needed
+ continue
+ except Exception as e:
+ self.register_error(plugin, PluginError("Error initializing __init__.py"), e)
+ continue
+
+ os.unlink(plugin_path) # temporary file no longer needed
+
+ found_plugin = False
+ for item in module.__dict__.items():
+ if inspect.isclass(item[1]) and item[0] != "PluginBaseClass" and issubclass(item[1], PluginBaseClass):
+ found_plugin = True
+ try:
+ item[1].instance_init()
+ except Exception as e:
+ self.register_error(
+ plugin, PluginError('Error running instance_init() on plugin "{}"'.format(plugin.name)), e
+ )
+ continue
+
+ self.plugins_by_id[plugin.id] = PluginModule(
+ id=plugin.id,
+ name=plugin.name,
+ tag=plugin.tag,
+ url=plugin.url,
+ module_name=module_name,
+ plugin_path=plugin_path,
+ requirements=requirements,
+ plugin=item[1],
+ module=module,
+ )
+
+ if found_plugin:
+ if local_plugin:
+ print('🔗 Loaded plugin "{}" from "{}"'.format(plugin.name, plugin_path))
+ else:
+ print(
+ '🔗 Loaded plugin "{}" from "{}" (cached, tag "{}")'.format(plugin.name, plugin.url, plugin.tag)
+ )
+ else:
+ self.register_error(plugin, PluginError("Could not find any exported class of type PluginBaseClass"))
+ continue
+
+ def unregister_plugin(self, id):
+ if not self.plugins_by_id.get(id, None):
+ return
+
+ # TODO: any way to properly remove the old one from memory?
+ # TODO: check also plugins_by_team and delete them from there...
+ del self.plugins_by_id[id].plugin
+ del self.plugins_by_id[id].module
+ del self.plugins_by_id[id]
+
+ def load_plugin_configs(self):
+ self.plugin_configs = list(
+ PluginConfig.objects.filter(enabled=True).order_by(F("team_id").desc(nulls_first=True), "order").all()
+ )
+ self.plugins_by_team = {}
+
+ for plugin_config in self.plugin_configs:
+ team_plugins = self.plugins_by_team.get(plugin_config.team_id, None)
+ if not team_plugins:
+ team_plugins = []
+ self.plugins_by_team[plugin_config.team_id] = team_plugins
+
+ plugin_module = self.plugins_by_id.get(plugin_config.plugin_id, None)
+
+ if plugin_module:
+ team_plugin = TeamPlugin(
+ team=plugin_config.team_id,
+ plugin=plugin_config.plugin_id,
+ order=plugin_config.order,
+ name=plugin_module.name,
+ tag=plugin_module.tag,
+ config=plugin_config.config,
+ plugin_module=plugin_module,
+ loaded_class=None,
+ )
+ try:
+ loaded_class = plugin_module.plugin(team_plugin)
+ team_plugin.loaded_class = loaded_class
+ team_plugins.append(team_plugin)
+ except Exception as e:
+ self.register_team_error(
+ team_plugin, PluginError("Error loading plugin"), e,
+ )
+
+ # if we have global plugins, add them to the team plugins list for all teams that have team plugins
+ global_plugins = self.plugins_by_team.get(None, None)
+ if global_plugins and len(global_plugins) > 0:
+ global_plugin_keys = {}
+ for plugin in global_plugins:
+ global_plugin_keys[plugin.name] = True
+ for team, team_plugins in self.plugins_by_team.items():
+ new_team_plugins = global_plugins.copy()
+ for plugin in team_plugins:
+ if not global_plugin_keys.get(plugin.name, None):
+ new_team_plugins.append(plugin)
+ new_team_plugins.sort(key=order_by_order)
+ self.plugins_by_team[team] = new_team_plugins
+
+ def install_requirements(self, plugin_name: str, requirements: List[str]):
+ if len(requirements) > 0:
+ print('Loading requirements for plugin "{}": {}'.format(plugin_name, requirements))
+
+ # TODO: Provide some way to work over version conflicts, e.g. if one plugin requires
+ # requests==2.22.0 and another requires requests==2.22.1. At least emit warnings!
+ for requirement in requirements:
+ if requirement:
+ self.install_requirement(requirement)
+
+ def install_requirement(self, requirement: str):
+ try:
+ import pip # type: ignore
+
+ if hasattr(pip, "main"):
+ resp = pip.main(["install", "-q", "--no-input", requirement])
+ else:
+ resp = pip._internal.main(["install", "-q", "--no-input", requirement])
+ except Exception as e:
+ raise PluginError("Exception when installing requirement '{}': {}".format(requirement, str(e)))
+
+ if resp != 0:
+ raise PluginError("Error installing requirement: {}".format(requirement))
+
+ def exec_plugins(self, event: PosthogEvent, team_id: int):
+ team_plugins = self.plugins_by_team.get(team_id, None)
+ global_plugins = self.plugins_by_team.get(None, [])
+ plugins_to_run = team_plugins if team_plugins else global_plugins
+
+ for team_plugin in plugins_to_run:
+ if event:
+ event = self.exec_plugin(team_plugin, event, "process_event")
+ if event and event.event == "$identify":
+ event = self.exec_plugin(team_plugin, event, "process_identify")
+ if event and event.event == "$create_alias":
+ event = self.exec_plugin(team_plugin, event, "process_alias")
+
+ return event
+
+ def exec_plugin(self, team_plugin: TeamPlugin, event: PosthogEvent, method="process_event"):
+ try:
+ f = getattr(team_plugin.loaded_class, method)
+ event = f(event)
+ except Exception as e:
+ self.register_team_error(
+ team_plugin, PluginError("Error running method '{}'".format(method)), e,
+ )
+ return event
+
+ def check_reload_plugins_periodically(self, seconds=10):
+ print(self.plugin_counter)
+ if self.last_plugins_check < datetime.now() - timedelta(seconds=seconds):
+ print("Checking reload!")
+ self.last_plugins_check = datetime.now()
+ self.check_reload_plugins()
+
+ def check_reload_plugins(self):
+ plugin_counter = self.get_plugin_counter()
+ if self.plugin_counter != plugin_counter:
+ print("Reloading!")
+ self.plugin_counter = plugin_counter
+ self.reload_plugins()
+
+ def reload_plugins(self):
+ self.load_plugins()
+ self.load_plugin_configs()
+
+ def get_plugin_counter(self):
+ return get_redis_instance().get("@posthog/plugin-reload") or 0
+
+ @staticmethod
+ def register_error(plugin: Union[Plugin, int], plugin_error: PluginError, error: Optional[Exception] = None):
+ if isinstance(plugin, int):
+ plugin = Plugin.objects.get(pk=plugin)
+
+ print('🔻🔻 Plugin name="{}", url="{}", tag="{}"'.format(plugin.name, plugin.url, plugin.tag))
+ print("🔻🔻 Error: {}".format(plugin_error.message))
+
+ plugin.error = {"message": plugin_error.message}
+ if error:
+ plugin.error["exception"] = str(error)
+ print("🔻🔻 Exception: {}".format(str(error)))
+ plugin.save()
+
+ @staticmethod
+ def register_team_error(team_plugin: TeamPlugin, plugin_error: PluginError, error: Optional[Exception] = None):
+ print('🔻🔻 Plugin name="{}", team="{}", tag="{}"'.format(team_plugin.name, team_plugin.team, team_plugin.tag))
+ print("🔻🔻 Error: {}".format(plugin_error.message))
+
+ plugin_configs = PluginConfig.objects.filter(team=team_plugin.team, plugin=team_plugin.plugin)
+ for plugin_config in plugin_configs:
+ plugin_config.error = {"message": plugin_error.message}
+ if error:
+ plugin_config.error["exception"] = str(error)
+ print("🔻🔻 Exception: {}".format(str(error)))
+ plugin_config.save()
+
+
+Plugins = SingletonDecorator(_Plugins)
+
+
+def order_by_order(e):
+ return e.order
diff --git a/posthog/plugins/sync.py b/posthog/plugins/sync.py
new file mode 100644
index 0000000000000..8879a089fabc9
--- /dev/null
+++ b/posthog/plugins/sync.py
@@ -0,0 +1,186 @@
+import json
+import os
+from typing import Any, Dict, List, Optional, Type
+
+from .utils import download_plugin_github_zip, load_json_file, load_json_zip_bytes
+
+
+def sync_posthog_json_plugins(raise_errors=False, filename="posthog.json"):
+ from posthog.models.plugin import Plugin
+
+ json_plugins = get_json_plugins(raise_errors=raise_errors, filename=filename)
+
+ config_plugins: Dict[str, Dict[str, Any]] = {}
+
+ for plugin in json_plugins:
+ if plugin and plugin.get("name", None):
+ config_plugins[plugin["name"]] = plugin
+
+ db_plugins = {}
+ for plugin in list(Plugin.objects.all()):
+ # was added from the CLI, but no longer requested
+ if plugin.from_json and not config_plugins.get(plugin.name, None):
+ if plugin.from_web:
+ plugin.from_json = False
+ plugin.save()
+ else:
+ plugin.delete()
+ continue
+ db_plugins[plugin.name] = plugin
+
+ for name, config_plugin in config_plugins.items():
+ db_plugin = db_plugins.get(name, None)
+ if not db_plugin:
+ create_plugin_from_config(config_plugin)
+ elif not config_and_db_plugin_in_sync(config_plugin, db_plugin):
+ update_plugin_from_config(db_plugin, config_plugin)
+
+
+def get_json_plugins(raise_errors=False, filename="posthog.json"):
+ try:
+ with open(filename, "r") as f:
+ return json.loads(f.read()).get("plugins", [])
+ except json.JSONDecodeError as e:
+ print_or_raise(
+ 'JSONDecodeError when reading "posthog.json". Skipping json plugin sync! Please investigate!', raise_errors
+ )
+ return []
+ except FileNotFoundError:
+ return []
+
+
+def create_plugin_from_config(config_plugin=None, raise_errors=False):
+ from posthog.models.plugin import Plugin
+
+ description = config_plugin.get("description", "")
+ config_schema = {}
+
+ if config_plugin.get("url", None):
+ if not config_plugin.get("tag", None):
+ print_or_raise(
+ 'No "tag" set for plugin "{}" enabled via posthog.json. Can\'t install!'.format(config_plugin["name"]),
+ raise_errors,
+ )
+ return
+ url = config_plugin["url"]
+ tag = config_plugin["tag"]
+ archive = download_plugin_github_zip(url, tag)
+ json = load_json_zip_bytes(archive, "plugin.json")
+ if json:
+ description = json["description"]
+ config_schema = json["config"]
+ elif config_plugin.get("path", None):
+ url = "file:{}".format(config_plugin["path"])
+ tag = ""
+ archive = None
+ json = load_json_file(os.path.join(config_plugin["path"], "plugin.json"))
+ if json:
+ description = json["description"]
+ config_schema = json["config"]
+ else:
+ print_or_raise(
+ 'No "url" or "path" set for plugin "{}" in posthog.json. Can\'t install!'.format(config_plugin["name"]),
+ raise_errors,
+ )
+ return
+
+ Plugin.objects.create(
+ name=config_plugin["name"],
+ description=description,
+ url=url,
+ tag=tag,
+ archive=archive,
+ config_schema=config_schema,
+ from_json=True,
+ )
+
+
+def config_and_db_plugin_in_sync(config_plugin, db_plugin):
+ url = config_plugin.get("url", "file:{}".format(config_plugin.get("path", "")))
+
+ return (
+ not url.startswith("file:")
+ and db_plugin.from_json
+ and db_plugin.url == url
+ and db_plugin.tag == config_plugin.get("tag", "")
+ )
+
+
+def update_plugin_from_config(db_plugin, config_plugin):
+ db_plugin.from_json = True
+ new_url = config_plugin.get("url", "file:{}".format(config_plugin.get("path", "")))
+ new_tag = config_plugin.get("tag", "")
+
+ if db_plugin.url.startswith("file:") or new_url != db_plugin.url or new_tag != db_plugin.tag:
+ db_plugin.url = new_url
+ db_plugin.tag = new_tag
+ if db_plugin.url.startswith("file:"):
+ db_plugin.archive = None
+ json = load_json_file(os.path.join(db_plugin.url.replace("file:", "", 1), "plugin.json"))
+ if json:
+ db_plugin.description = json["description"]
+ db_plugin.config_schema = json["config"]
+ else:
+ db_plugin.archive = download_plugin_github_zip(db_plugin.url, db_plugin.tag)
+ json = load_json_zip_bytes(db_plugin.archive, "plugin.json")
+ if json:
+ db_plugin.description = json["description"]
+ db_plugin.config_schema = json["config"]
+
+ db_plugin.save()
+
+
+def print_or_raise(msg, raise_errors):
+ if raise_errors:
+ raise Exception(msg)
+ print("🔻🔻 {}".format(msg))
+
+
+def sync_global_plugin_config(filename="posthog.json"):
+ from posthog.models.plugin import Plugin, PluginConfig
+
+ posthog_json = load_json_file(filename)
+
+ # get all plugins with global configs from posthog.json
+ json_plugin_configs = {}
+ if posthog_json and posthog_json.get("plugins", None):
+ for plugin in posthog_json["plugins"]:
+ global_config = plugin.get("global", None)
+ if global_config:
+ json_plugin_configs[plugin["name"]] = global_config
+
+ # what plugins actually exist in the db?
+ db_plugins = {}
+ for plugin in Plugin.objects.all():
+ db_plugins[plugin.name] = plugin
+
+ # get all global plugins configs from the db... delete if not in posthog.json or plugin not installed
+ db_plugin_configs = {}
+ for plugin_config in list(PluginConfig.objects.filter(team=None)):
+ name = plugin_config.plugin.name
+ if not json_plugin_configs.get(name, None) or not db_plugins.get(name, None):
+ plugin_config.delete()
+ continue
+ db_plugin_configs[name] = plugin_config
+
+ # add new and update changed configs into the db
+ for name, plugin_json in json_plugin_configs.items():
+ enabled = plugin_json.get("enabled", False)
+ order = plugin_json.get("order", 0)
+ config = plugin_json.get("config", {})
+
+ db_plugin_config = db_plugin_configs.get(name, None)
+ if db_plugin_config:
+ if (
+ db_plugin_config.enabled != enabled
+ or db_plugin_config.order != order
+ or json.dumps(db_plugin_config.config) != json.dumps(config)
+ ):
+ db_plugin_config.enabled = enabled
+ db_plugin_config.order = order
+ db_plugin_config.config = config
+ db_plugin_config.save()
+ elif db_plugins.get(name, None):
+ PluginConfig.objects.create(
+ team=None, plugin=db_plugins[name], enabled=enabled, order=order, config=config,
+ )
diff --git a/posthog/plugins/test/__init__.py b/posthog/plugins/test/__init__.py
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/posthog/plugins/test/plugin_archives.py b/posthog/plugins/test/plugin_archives.py
new file mode 100644
index 0000000000000..1d78c01cdf378
--- /dev/null
+++ b/posthog/plugins/test/plugin_archives.py
@@ -0,0 +1,48 @@
+HELLO_WORLD_PLUGIN = (
+ "3c4c77e7d7878e87be3c2373b658c74ec3085f49",
+ "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VAUAAVuPgF9tYXJzaG1hbGxvdz09My44LjBQSwECAAAKAAAAAABjS0lRAAAAAAAAAAAAAAAAOgAJAAAAAAAAABAAAAAAAAAAaGVsbG93b3JsZHBsdWdpbi0zYzRjNzdlN2Q3ODc4ZTg3YmUzYzIzNzNiNjU4Yzc0ZWMzMDg1ZjQ5L1VUBQABW4+AX1BLAQIAAAoAAAAIAGNLSVEcqXWpxwMAAAcHAABEAAkAAAAAAAEAAAAAAGEAAABoZWxsb3dvcmxkcGx1Z2luLTNjNGM3N2U3ZDc4NzhlODdiZTNjMjM3M2I2NThjNzRlYzMwODVmNDkvLmdpdGlnbm9yZVVUBQABW4+AX1BLAQIAAAoAAAAIAGNLSVHBbrBydgIAADAEAABBAAkAAAAAAAEAAAAAAJMEAABoZWxsb3dvcmxkcGx1Z2luLTNjNGM3N2U3ZDc4NzhlODdiZTNjMjM3M2I2NThjNzRlYzMwODVmNDkvTElDRU5TRVVUBQABW4+AX1BLAQIAAAoAAAAIAGNLSVEf4ES4IwAAAC4AAABDAAkAAAAAAAEAAAAAAHEHAABoZWxsb3dvcmxkcGx1Z2luLTNjNGM3N2U3ZDc4NzhlODdiZTNjMjM3M2I2NThjNzRlYzMwODVmNDkvUkVBRE1FLm1kVVQFAAFbj4BfUEsBAgAACgAAAAgAY0tJUfunF22eAAAADQEAAEUACQAAAAAAAQAAAAAA/gcAAGhlbGxvd29ybGRwbHVnaW4tM2M0Yzc3ZTdkNzg3OGU4N2JlM2MyMzczYjY1OGM3NGVjMzA4NWY0OS9fX2luaXRfXy5weVVUBQABW4+AX1BLAQIAAAoAAAAIAGNLSVEes+m/rwAAAB0BAABFAAkAAAAAAAEAAAAAAAgJAABoZWxsb3dvcmxkcGx1Z2luLTNjNGM3N2U3ZDc4NzhlODdiZTNjMjM3M2I2NThjNzRlYzMwODVmNDkvcGx1Z2luLmpzb25VVAUAAVuPgF9QSwECAAAKAAAAAABjS0lRowiz6RIAAAASAAAASgAJAAAAAAABAAAAAAAjCgAAaGVsbG93b3JsZHBsdWdpbi0zYzRjNzdlN2Q3ODc4ZTg3YmUzYzIzNzNiNjU4Yzc0ZWMzMDg1ZjQ5L3JlcXVpcmVtZW50cy50eHRVVAUAAVuPgF9QSwUGAAAAAAcABwBXAwAApgoAACgAM2M0Yzc3ZTdkNzg3OGU4N2JlM2MyMzczYjY1OGM3NGVjMzA4NWY0OQ==",
+)
+BROKEN_PLUGIN_JSON = (
+ "cf5c5d556cff59bde7eac7ae8264dfca31f31f05",
+ "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AAA6PFBRAAAAAAAAAAAAAAAAOgAJAAAAAAAAABAAAAAAAAAAaGVsbG93b3JsZHBsdWdpbi1jZjVjNWQ1NTZjZmY1OWJkZTdlYWM3YWU4MjY0ZGZjYTMxZjMxZjA1L1VUBQABUK+JX1BLAQIAAAoAAAAIADo8UFE28pArygMAAAwHAABEAAkAAAAAAAEAAAAAAGEAAABoZWxsb3dvcmxkcGx1Z2luLWNmNWM1ZDU1NmNmZjU5YmRlN2VhYzdhZTgyNjRkZmNhMzFmMzFmMDUvLmdpdGlnbm9yZVVUBQABUK+JX1BLAQIAAAoAAAAIADo8UFHBbrBydgIAADAEAABBAAkAAAAAAAEAAAAAAJYEAABoZWxsb3dvcmxkcGx1Z2luLWNmNWM1ZDU1NmNmZjU5YmRlN2VhYzdhZTgyNjRkZmNhMzFmMzFmMDUvTElDRU5TRVVUBQABUK+JX1BLAQIAAAoAAAAIADo8UFEf4ES4IwAAAC4AAABDAAkAAAAAAAEAAAAAAHQHAABoZWxsb3dvcmxkcGx1Z2luLWNmNWM1ZDU1NmNmZjU5YmRlN2VhYzdhZTgyNjRkZmNhMzFmMzFmMDUvUkVBRE1FLm1kVVQFAAFQr4lfUEsBAgAACgAAAAgAOjxQUfunF22eAAAADQEAAEUACQAAAAAAAQAAAAAAAQgAAGhlbGxvd29ybGRwbHVnaW4tY2Y1YzVkNTU2Y2ZmNTliZGU3ZWFjN2FlODI2NGRmY2EzMWYzMWYwNS9fX2luaXRfXy5weVVUBQABUK+JX1BLAQIAAAoAAAAIADo8UFEwXcquiAAAAMkAAABFAAkAAAAAAAEAAAAAAAsJAABoZWxsb3dvcmxkcGx1Z2luLWNmNWM1ZDU1NmNmZjU5YmRlN2VhYzdhZTgyNjRkZmNhMzFmMzFmMDUvcGx1Z2luLmpzb25VVAUAAVCviV9QSwECAAAKAAAAAAA6PFBRowiz6RIAAAASAAAASgAJAAAAAAABAAAAAAD/CQAAaGVsbG93b3JsZHBsdWdpbi1jZjVjNWQ1NTZjZmY1OWJkZTdlYWM3YWU4MjY0ZGZjYTMxZjMxZjA1L3JlcXVpcmVtZW50cy50eHRVVAUAAVCviV9QSwUGAAAAAAcABwBXAwAAggoAACgAY2Y1YzVkNTU2Y2ZmNTliZGU3ZWFjN2FlODI2NGRmY2EzMWYzMWYwNQ==",
+)
+BROKEN_REQUIREMENTS_TXT = (
+ "52d9f725e1425913fe86f20ebc91b064059123e8",
+ "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eHRVVAUAAWOviV9TdFB0UIah3MSi4ozcxJyc/HJbW2M9Cz0DAFBLAQIAAAoAAAAAAEU8UFEAAAAAAAAAAAAAAAA6AAkAAAAAAAAAEAAAAAAAAABoZWxsb3dvcmxkcGx1Z2luLTUyZDlmNzI1ZTE0MjU5MTNmZTg2ZjIwZWJjOTFiMDY0MDU5MTIzZTgvVVQFAAFjr4lfUEsBAgAACgAAAAgARTxQUTbykCvKAwAADAcAAEQACQAAAAAAAQAAAAAAYQAAAGhlbGxvd29ybGRwbHVnaW4tNTJkOWY3MjVlMTQyNTkxM2ZlODZmMjBlYmM5MWIwNjQwNTkxMjNlOC8uZ2l0aWdub3JlVVQFAAFjr4lfUEsBAgAACgAAAAgARTxQUcFusHJ2AgAAMAQAAEEACQAAAAAAAQAAAAAAlgQAAGhlbGxvd29ybGRwbHVnaW4tNTJkOWY3MjVlMTQyNTkxM2ZlODZmMjBlYmM5MWIwNjQwNTkxMjNlOC9MSUNFTlNFVVQFAAFjr4lfUEsBAgAACgAAAAgARTxQUR/gRLgjAAAALgAAAEMACQAAAAAAAQAAAAAAdAcAAGhlbGxvd29ybGRwbHVnaW4tNTJkOWY3MjVlMTQyNTkxM2ZlODZmMjBlYmM5MWIwNjQwNTkxMjNlOC9SRUFETUUubWRVVAUAAWOviV9QSwECAAAKAAAACABFPFBR+6cXbZ4AAAANAQAARQAJAAAAAAABAAAAAAABCAAAaGVsbG93b3JsZHBsdWdpbi01MmQ5ZjcyNWUxNDI1OTEzZmU4NmYyMGViYzkxYjA2NDA1OTEyM2U4L19faW5pdF9fLnB5VVQFAAFjr4lfUEsBAgAACgAAAAgARTxQUR6z6b+vAAAAHQEAAEUACQAAAAAAAQAAAAAACwkAAGhlbGxvd29ybGRwbHVnaW4tNTJkOWY3MjVlMTQyNTkxM2ZlODZmMjBlYmM5MWIwNjQwNTkxMjNlOC9wbHVnaW4uanNvblVUBQABY6+JX1BLAQIAAAoAAAAIAEU8UFG5r17ZGgAAAB8AAABKAAkAAAAAAAEAAAAAACYKAABoZWxsb3dvcmxkcGx1Z2luLTUyZDlmNzI1ZTE0MjU5MTNmZTg2ZjIwZWJjOTFiMDY0MDU5MTIzZTgvcmVxdWlyZW1lbnRzLnR4dFVUBQABY6+JX1BLBQYAAAAABwAHAFcDAACxCgAAKAA1MmQ5ZjcyNWUxNDI1OTEzZmU4NmYyMGViYzkxYjA2NDA1OTEyM2U4",
+)
+NO_REQUIREMENTS_TXT = (
+ "4bdafe50727837cb382919128a4e688caa1f9e0d",
+ "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ZC9VVAUAAZCviV9QSwECAAAKAAAACABcPFBRNvKQK8oDAAAMBwAARAAJAAAAAAABAAAAAABhAAAAaGVsbG93b3JsZHBsdWdpbi00YmRhZmU1MDcyNzgzN2NiMzgyOTE5MTI4YTRlNjg4Y2FhMWY5ZTBkLy5naXRpZ25vcmVVVAUAAZCviV9QSwECAAAKAAAACABcPFBRwW6wcnYCAAAwBAAAQQAJAAAAAAABAAAAAACWBAAAaGVsbG93b3JsZHBsdWdpbi00YmRhZmU1MDcyNzgzN2NiMzgyOTE5MTI4YTRlNjg4Y2FhMWY5ZTBkL0xJQ0VOU0VVVAUAAZCviV9QSwECAAAKAAAACABcPFBRH+BEuCMAAAAuAAAAQwAJAAAAAAABAAAAAAB0BwAAaGVsbG93b3JsZHBsdWdpbi00YmRhZmU1MDcyNzgzN2NiMzgyOTE5MTI4YTRlNjg4Y2FhMWY5ZTBkL1JFQURNRS5tZFVUBQABkK+JX1BLAQIAAAoAAAAIAFw8UFH7pxdtngAAAA0BAABFAAkAAAAAAAEAAAAAAAEIAABoZWxsb3dvcmxkcGx1Z2luLTRiZGFmZTUwNzI3ODM3Y2IzODI5MTkxMjhhNGU2ODhjYWExZjllMGQvX19pbml0X18ucHlVVAUAAZCviV9QSwECAAAKAAAACABcPFBRHrPpv68AAAAdAQAARQAJAAAAAAABAAAAAAALCQAAaGVsbG93b3JsZHBsdWdpbi00YmRhZmU1MDcyNzgzN2NiMzgyOTE5MTI4YTRlNjg4Y2FhMWY5ZTBkL3BsdWdpbi5qc29uVVQFAAGQr4lfUEsFBgAAAAAGAAYA1gIAACYKAAAoADRiZGFmZTUwNzI3ODM3Y2IzODI5MTkxMjhhNGU2ODhjYWExZjllMGQ=",
+)
+NO_PLUGIN_JSON = (
+ "00fc8780999304fd0bdc175df088b0f7d040981e",
+ "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sHJ2AgAAMAQAAEEACQAAAAAAAQAAAAAAlgQAAGhlbGxvd29ybGRwbHVnaW4tMDBmYzg3ODA5OTkzMDRmZDBiZGMxNzVkZjA4OGIwZjdkMDQwOTgxZS9MSUNFTlNFVVQFAAGor4lfUEsBAgAACgAAAAgAajxQUR/gRLgjAAAALgAAAEMACQAAAAAAAQAAAAAAdAcAAGhlbGxvd29ybGRwbHVnaW4tMDBmYzg3ODA5OTkzMDRmZDBiZGMxNzVkZjA4OGIwZjdkMDQwOTgxZS9SRUFETUUubWRVVAUAAaiviV9QSwECAAAKAAAACABqPFBR+6cXbZ4AAAANAQAARQAJAAAAAAABAAAAAAABCAAAaGVsbG93b3JsZHBsdWdpbi0wMGZjODc4MDk5OTMwNGZkMGJkYzE3NWRmMDg4YjBmN2QwNDA5ODFlL19faW5pdF9fLnB5VVQFAAGor4lfUEsBAgAACgAAAAAAajxQUaMIs+kSAAAAEgAAAEoACQAAAAAAAQAAAAAACwkAAGhlbGxvd29ybGRwbHVnaW4tMDBmYzg3ODA5OTkzMDRmZDBiZGMxNzVkZjA4OGIwZjdkMDQwOTgxZS9yZXF1aXJlbWVudHMudHh0VVQFAAGor4lfUEsFBgAAAAAGAAYA2wIAAI4JAAAoADAwZmM4NzgwOTk5MzA0ZmQwYmRjMTc1ZGYwODhiMGY3ZDA0MDk4MWU=",
+)
+NO_INIT_PY = (
+ "2dfff8b5d6053d815ab4b555b1be4282857bd2a4",
+ "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SwECAAAKAAAACACIPFBRwW6wcnYCAAAwBAAAQQAJAAAAAAABAAAAAACWBAAAaGVsbG93b3JsZHBsdWdpbi0yZGZmZjhiNWQ2MDUzZDgxNWFiNGI1NTViMWJlNDI4Mjg1N2JkMmE0L0xJQ0VOU0VVVAUAAeGviV9QSwECAAAKAAAACACIPFBRH+BEuCMAAAAuAAAAQwAJAAAAAAABAAAAAAB0BwAAaGVsbG93b3JsZHBsdWdpbi0yZGZmZjhiNWQ2MDUzZDgxNWFiNGI1NTViMWJlNDI4Mjg1N2JkMmE0L1JFQURNRS5tZFVUBQAB4a+JX1BLAQIAAAoAAAAIAIg8UFEes+m/rwAAAB0BAABFAAkAAAAAAAEAAAAAAAEIAABoZWxsb3dvcmxkcGx1Z2luLTJkZmZmOGI1ZDYwNTNkODE1YWI0YjU1NWIxYmU0MjgyODU3YmQyYTQvcGx1Z2luLmpzb25VVAUAAeGviV9QSwECAAAKAAAAAACIPFBRowiz6RIAAAASAAAASgAJAAAAAAABAAAAAAAcCQAAaGVsbG93b3JsZHBsdWdpbi0yZGZmZjhiNWQ2MDUzZDgxNWFiNGI1NTViMWJlNDI4Mjg1N2JkMmE0L3JlcXVpcmVtZW50cy50eHRVVAUAAeGviV9QSwUGAAAAAAYABgDbAgAAnwkAACgAMmRmZmY4YjVkNjA1M2Q4MTVhYjRiNTU1YjFiZTQyODI4NTdiZDJhNA==",
+)
+INIT_EXCEPTION = (
+ "8069f075955170e5621e406c2e138d74573303d2",
+ "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Mi9yZXF1aXJlbWVudHMudHh0VVQFAAEEsIlfbWFyc2htYWxsb3c9PTMuOC4wUEsBAgAACgAAAAAAmjxQUQAAAAAAAAAAAAAAADoACQAAAAAAAAAQAAAAAAAAAGhlbGxvd29ybGRwbHVnaW4tODA2OWYwNzU5NTUxNzBlNTYyMWU0MDZjMmUxMzhkNzQ1NzMzMDNkMi9VVAUAAQSwiV9QSwECAAAKAAAACACaPFBRNvKQK8oDAAAMBwAARAAJAAAAAAABAAAAAABhAAAAaGVsbG93b3JsZHBsdWdpbi04MDY5ZjA3NTk1NTE3MGU1NjIxZTQwNmMyZTEzOGQ3NDU3MzMwM2QyLy5naXRpZ25vcmVVVAUAAQSwiV9QSwECAAAKAAAACACaPFBRwW6wcnYCAAAwBAAAQQAJAAAAAAABAAAAAACWBAAAaGVsbG93b3JsZHBsdWdpbi04MDY5ZjA3NTk1NTE3MGU1NjIxZTQwNmMyZTEzOGQ3NDU3MzMwM2QyL0xJQ0VOU0VVVAUAAQSwiV9QSwECAAAKAAAACACaPFBRH+BEuCMAAAAuAAAAQwAJAAAAAAABAAAAAAB0BwAAaGVsbG93b3JsZHBsdWdpbi04MDY5ZjA3NTk1NTE3MGU1NjIxZTQwNmMyZTEzOGQ3NDU3MzMwM2QyL1JFQURNRS5tZFVUBQABBLCJX1BLAQIAAAoAAAAIAJo8UFGYwWO7rgAAACYBAABFAAkAAAAAAAEAAAAAAAEIAABoZWxsb3dvcmxkcGx1Z2luLTgwNjlmMDc1OTU1MTcwZTU2MjFlNDA2YzJlMTM4ZDc0NTczMzAzZDIvX19pbml0X18ucHlVVAUAAQSwiV9QSwECAAAKAAAACACaPFBRHrPpv68AAAAdAQAARQAJAAAAAAABAAAAAAAbCQAAaGVsbG93b3JsZHBsdWdpbi04MDY5ZjA3NTk1NTE3MGU1NjIxZTQwNmMyZTEzOGQ3NDU3MzMwM2QyL3BsdWdpbi5qc29uVVQFAAEEsIlfUEsBAgAACgAAAAAAmjxQUaMIs+kSAAAAEgAAAEoACQAAAAAAAQAAAAAANgoAAGhlbGxvd29ybGRwbHVnaW4tODA2OWYwNzU5NTUxNzBlNTYyMWU0MDZjMmUxMzhkNzQ1NzMzMDNkMi9yZXF1aXJlbWVudHMudHh0VVQFAAEEsIlfUEsFBgAAAAAHAAcAVwMAALkKAAAoADgwNjlmMDc1OTU1MTcwZTU2MjFlNDA2YzJlMTM4ZDc0NTczMzAzZDI=",
+)
+INIT_SYNTAX = (
+ "bdabcd87505db2fc714646e729c297b827667f84",
+ "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ARSwiV9tYXJzaG1hbGxvdz09My44LjBQSwECAAAKAAAAAACkPFBRAAAAAAAAAAAAAAAAOgAJAAAAAAAAABAAAAAAAAAAaGVsbG93b3JsZHBsdWdpbi1iZGFiY2Q4NzUwNWRiMmZjNzE0NjQ2ZTcyOWMyOTdiODI3NjY3Zjg0L1VUBQABFLCJX1BLAQIAAAoAAAAIAKQ8UFE28pArygMAAAwHAABEAAkAAAAAAAEAAAAAAGEAAABoZWxsb3dvcmxkcGx1Z2luLWJkYWJjZDg3NTA1ZGIyZmM3MTQ2NDZlNzI5YzI5N2I4Mjc2NjdmODQvLmdpdGlnbm9yZVVUBQABFLCJX1BLAQIAAAoAAAAIAKQ8UFHBbrBydgIAADAEAABBAAkAAAAAAAEAAAAAAJYEAABoZWxsb3dvcmxkcGx1Z2luLWJkYWJjZDg3NTA1ZGIyZmM3MTQ2NDZlNzI5YzI5N2I4Mjc2NjdmODQvTElDRU5TRVVUBQABFLCJX1BLAQIAAAoAAAAIAKQ8UFEf4ES4IwAAAC4AAABDAAkAAAAAAAEAAAAAAHQHAABoZWxsb3dvcmxkcGx1Z2luLWJkYWJjZDg3NTA1ZGIyZmM3MTQ2NDZlNzI5YzI5N2I4Mjc2NjdmODQvUkVBRE1FLm1kVVQFAAEUsIlfUEsBAgAACgAAAAgApDxQUb4bcjmYAAAA/gAAAEUACQAAAAAAAQAAAAAAAQgAAGhlbGxvd29ybGRwbHVnaW4tYmRhYmNkODc1MDVkYjJmYzcxNDY0NmU3MjljMjk3YjgyNzY2N2Y4NC9fX2luaXRfXy5weVVUBQABFLCJX1BLAQIAAAoAAAAIAKQ8UFEes+m/rwAAAB0BAABFAAkAAAAAAAEAAAAAAAUJAABoZWxsb3dvcmxkcGx1Z2luLWJkYWJjZDg3NTA1ZGIyZmM3MTQ2NDZlNzI5YzI5N2I4Mjc2NjdmODQvcGx1Z2luLmpzb25VVAUAARSwiV9QSwECAAAKAAAAAACkPFBRowiz6RIAAAASAAAASgAJAAAAAAABAAAAAAAgCgAAaGVsbG93b3JsZHBsdWdpbi1iZGFiY2Q4NzUwNWRiMmZjNzE0NjQ2ZTcyOWMyOTdiODI3NjY3Zjg0L3JlcXVpcmVtZW50cy50eHRVVAUAARSwiV9QSwUGAAAAAAcABwBXAwAAowoAACgAYmRhYmNkODc1MDVkYjJmYzcxNDY0NmU3MjljMjk3YjgyNzY2N2Y4NA==",
+)
+TEST_TEAM_INSTANCE_INIT = (
+ "bd5bb06101606953e36caa4cbd9de7b7b39e7f89",
+ "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Y2FhNGNiZDlkZTdiN2IzOWU3Zjg5L3JlcXVpcmVtZW50cy50eHRVVAUAAX2wiV9tYXJzaG1hbGxvdz09My44LjBQSwECAAAKAAAAAADaPFBRAAAAAAAAAAAAAAAAOgAJAAAAAAAAABAAAAAAAAAAaGVsbG93b3JsZHBsdWdpbi1iZDViYjA2MTAxNjA2OTUzZTM2Y2FhNGNiZDlkZTdiN2IzOWU3Zjg5L1VUBQABfbCJX1BLAQIAAAoAAAAIANo8UFE28pArygMAAAwHAABEAAkAAAAAAAEAAAAAAGEAAABoZWxsb3dvcmxkcGx1Z2luLWJkNWJiMDYxMDE2MDY5NTNlMzZjYWE0Y2JkOWRlN2I3YjM5ZTdmODkvLmdpdGlnbm9yZVVUBQABfbCJX1BLAQIAAAoAAAAIANo8UFHBbrBydgIAADAEAABBAAkAAAAAAAEAAAAAAJYEAABoZWxsb3dvcmxkcGx1Z2luLWJkNWJiMDYxMDE2MDY5NTNlMzZjYWE0Y2JkOWRlN2I3YjM5ZTdmODkvTElDRU5TRVVUBQABfbCJX1BLAQIAAAoAAAAIANo8UFEf4ES4IwAAAC4AAABDAAkAAAAAAAEAAAAAAHQHAABoZWxsb3dvcmxkcGx1Z2luLWJkNWJiMDYxMDE2MDY5NTNlMzZjYWE0Y2JkOWRlN2I3YjM5ZTdmODkvUkVBRE1FLm1kVVQFAAF9sIlfUEsBAgAACgAAAAgA2jxQUcBWlUTCAAAAmwEAAEUACQAAAAAAAQAAAAAAAQgAAGhlbGxvd29ybGRwbHVnaW4tYmQ1YmIwNjEwMTYwNjk1M2UzNmNhYTRjYmQ5ZGU3YjdiMzllN2Y4OS9fX2luaXRfXy5weVVUBQABfbCJX1BLAQIAAAoAAAAIANo8UFEes+m/rwAAAB0BAABFAAkAAAAAAAEAAAAAAC8JAABoZWxsb3dvcmxkcGx1Z2luLWJkNWJiMDYxMDE2MDY5NTNlMzZjYWE0Y2JkOWRlN2I3YjM5ZTdmODkvcGx1Z2luLmpzb25VVAUAAX2wiV9QSwECAAAKAAAAAADaPFBRowiz6RIAAAASAAAASgAJAAAAAAABAAAAAABKCgAAaGVsbG93b3JsZHBsdWdpbi1iZDViYjA2MTAxNjA2OTUzZTM2Y2FhNGNiZDlkZTdiN2IzOWU3Zjg5L3JlcXVpcmVtZW50cy50eHRVVAUAAX2wiV9QSwUGAAAAAAcABwBXAwAAzQoAACgAYmQ1YmIwNjEwMTYwNjk1M2UzNmNhYTRjYmQ5ZGU3YjdiMzllN2Y4OQ==",
+)
+RAISE_EVENT = (
+ "64f428010a1c850f0a0764b95ed439c985a72d12",
+ "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UEsBAgAACgAAAAAA6jxQUQAAAAAAAAAAAAAAADoACQAAAAAAAAAQAAAAAAAAAGhlbGxvd29ybGRwbHVnaW4tNjRmNDI4MDEwYTFjODUwZjBhMDc2NGI5NWVkNDM5Yzk4NWE3MmQxMi9VVAUAAZiwiV9QSwECAAAKAAAACADqPFBRNvKQK8oDAAAMBwAARAAJAAAAAAABAAAAAABhAAAAaGVsbG93b3JsZHBsdWdpbi02NGY0MjgwMTBhMWM4NTBmMGEwNzY0Yjk1ZWQ0MzljOTg1YTcyZDEyLy5naXRpZ25vcmVVVAUAAZiwiV9QSwECAAAKAAAACADqPFBRwW6wcnYCAAAwBAAAQQAJAAAAAAABAAAAAACWBAAAaGVsbG93b3JsZHBsdWdpbi02NGY0MjgwMTBhMWM4NTBmMGEwNzY0Yjk1ZWQ0MzljOTg1YTcyZDEyL0xJQ0VOU0VVVAUAAZiwiV9QSwECAAAKAAAACADqPFBRH+BEuCMAAAAuAAAAQwAJAAAAAAABAAAAAAB0BwAAaGVsbG93b3JsZHBsdWdpbi02NGY0MjgwMTBhMWM4NTBmMGEwNzY0Yjk1ZWQ0MzljOTg1YTcyZDEyL1JFQURNRS5tZFVUBQABmLCJX1BLAQIAAAoAAAAIAOo8UFE2Pq8rgQAAALwAAABFAAkAAAAAAAEAAAAAAAEIAABoZWxsb3dvcmxkcGx1Z2luLTY0ZjQyODAxMGExYzg1MGYwYTA3NjRiOTVlZDQzOWM5ODVhNzJkMTIvX19pbml0X18ucHlVVAUAAZiwiV9QSwECAAAKAAAACADqPFBRHrPpv68AAAAdAQAARQAJAAAAAAABAAAAAADuCAAAaGVsbG93b3JsZHBsdWdpbi02NGY0MjgwMTBhMWM4NTBmMGEwNzY0Yjk1ZWQ0MzljOTg1YTcyZDEyL3BsdWdpbi5qc29uVVQFAAGYsIlfUEsBAgAACgAAAAAA6jxQUaMIs+kSAAAAEgAAAEoACQAAAAAAAQAAAAAACQoAAGhlbGxvd29ybGRwbHVnaW4tNjRmNDI4MDEwYTFjODUwZjBhMDc2NGI5NWVkNDM5Yzk4NWE3MmQxMi9yZXF1aXJlbWVudHMudHh0VVQFAAGYsIlfUEsFBgAAAAAHAAcAVwMAAIwKAAAoADY0ZjQyODAxMGExYzg1MGYwYTA3NjRiOTVlZDQzOWM5ODVhNzJkMTI=",
+)
+RAISE_INSTANCE_INIT = (
+ "241d6f5035f4cf8e533a417cdd526e46a8c69b62",
+ "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LjguMFBLAQIAAAoAAAAAAAQ9UFEAAAAAAAAAAAAAAAA6AAkAAAAAAAAAEAAAAAAAAABoZWxsb3dvcmxkcGx1Z2luLTI0MWQ2ZjUwMzVmNGNmOGU1MzNhNDE3Y2RkNTI2ZTQ2YThjNjliNjIvVVQFAAHIsIlfUEsBAgAACgAAAAgABD1QUTbykCvKAwAADAcAAEQACQAAAAAAAQAAAAAAYQAAAGhlbGxvd29ybGRwbHVnaW4tMjQxZDZmNTAzNWY0Y2Y4ZTUzM2E0MTdjZGQ1MjZlNDZhOGM2OWI2Mi8uZ2l0aWdub3JlVVQFAAHIsIlfUEsBAgAACgAAAAgABD1QUcFusHJ2AgAAMAQAAEEACQAAAAAAAQAAAAAAlgQAAGhlbGxvd29ybGRwbHVnaW4tMjQxZDZmNTAzNWY0Y2Y4ZTUzM2E0MTdjZGQ1MjZlNDZhOGM2OWI2Mi9MSUNFTlNFVVQFAAHIsIlfUEsBAgAACgAAAAgABD1QUR/gRLgjAAAALgAAAEMACQAAAAAAAQAAAAAAdAcAAGhlbGxvd29ybGRwbHVnaW4tMjQxZDZmNTAzNWY0Y2Y4ZTUzM2E0MTdjZGQ1MjZlNDZhOGM2OWI2Mi9SRUFETUUubWRVVAUAAciwiV9QSwECAAAKAAAACAAEPVBRx0EnzYUAAAC4AAAARQAJAAAAAAABAAAAAAABCAAAaGVsbG93b3JsZHBsdWdpbi0yNDFkNmY1MDM1ZjRjZjhlNTMzYTQxN2NkZDUyNmU0NmE4YzY5YjYyL19faW5pdF9fLnB5VVQFAAHIsIlfUEsBAgAACgAAAAgABD1QUR6z6b+vAAAAHQEAAEUACQAAAAAAAQAAAAAA8ggAAGhlbGxvd29ybGRwbHVnaW4tMjQxZDZmNTAzNWY0Y2Y4ZTUzM2E0MTdjZGQ1MjZlNDZhOGM2OWI2Mi9wbHVnaW4uanNvblVUBQAByLCJX1BLAQIAAAoAAAAAAAQ9UFGjCLPpEgAAABIAAABKAAkAAAAAAAEAAAAAAA0KAABoZWxsb3dvcmxkcGx1Z2luLTI0MWQ2ZjUwMzVmNGNmOGU1MzNhNDE3Y2RkNTI2ZTQ2YThjNjliNjIvcmVxdWlyZW1lbnRzLnR4dFVUBQAByLCJX1BLBQYAAAAABwAHAFcDAACQCgAAKAAyNDFkNmY1MDM1ZjRjZjhlNTMzYTQxN2NkZDUyNmU0NmE4YzY5YjYy",
+)
+RAISE_TEAM_INIT = (
+ "148292bf6bfd3896a1f1b591a51f89afef5cb79d",
+ "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UEsBAgAACgAAAAAACz1QUQAAAAAAAAAAAAAAADoACQAAAAAAAAAQAAAAAAAAAGhlbGxvd29ybGRwbHVnaW4tMTQ4MjkyYmY2YmZkMzg5NmExZjFiNTkxYTUxZjg5YWZlZjVjYjc5ZC9VVAUAAdewiV9QSwECAAAKAAAACAALPVBRNvKQK8oDAAAMBwAARAAJAAAAAAABAAAAAABhAAAAaGVsbG93b3JsZHBsdWdpbi0xNDgyOTJiZjZiZmQzODk2YTFmMWI1OTFhNTFmODlhZmVmNWNiNzlkLy5naXRpZ25vcmVVVAUAAdewiV9QSwECAAAKAAAACAALPVBRwW6wcnYCAAAwBAAAQQAJAAAAAAABAAAAAACWBAAAaGVsbG93b3JsZHBsdWdpbi0xNDgyOTJiZjZiZmQzODk2YTFmMWI1OTFhNTFmODlhZmVmNWNiNzlkL0xJQ0VOU0VVVAUAAdewiV9QSwECAAAKAAAACAALPVBRH+BEuCMAAAAuAAAAQwAJAAAAAAABAAAAAAB0BwAAaGVsbG93b3JsZHBsdWdpbi0xNDgyOTJiZjZiZmQzODk2YTFmMWI1OTFhNTFmODlhZmVmNWNiNzlkL1JFQURNRS5tZFVUBQAB17CJX1BLAQIAAAoAAAAIAAs9UFGZk5oZgQAAALMAAABFAAkAAAAAAAEAAAAAAAEIAABoZWxsb3dvcmxkcGx1Z2luLTE0ODI5MmJmNmJmZDM4OTZhMWYxYjU5MWE1MWY4OWFmZWY1Y2I3OWQvX19pbml0X18ucHlVVAUAAdewiV9QSwECAAAKAAAACAALPVBRHrPpv68AAAAdAQAARQAJAAAAAAABAAAAAADuCAAAaGVsbG93b3JsZHBsdWdpbi0xNDgyOTJiZjZiZmQzODk2YTFmMWI1OTFhNTFmODlhZmVmNWNiNzlkL3BsdWdpbi5qc29uVVQFAAHXsIlfUEsBAgAACgAAAAAACz1QUaMIs+kSAAAAEgAAAEoACQAAAAAAAQAAAAAACQoAAGhlbGxvd29ybGRwbHVnaW4tMTQ4MjkyYmY2YmZkMzg5NmExZjFiNTkxYTUxZjg5YWZlZjVjYjc5ZC9yZXF1aXJlbWVudHMudHh0VVQFAAHXsIlfUEsFBgAAAAAHAAcAVwMAAIwKAAAoADE0ODI5MmJmNmJmZDM4OTZhMWYxYjU5MWE1MWY4OWFmZWY1Y2I3OWQ=",
+)
diff --git a/posthog/plugins/test/test_plugins.py b/posthog/plugins/test/test_plugins.py
new file mode 100644
index 0000000000000..b3fb787f4e9ee
--- /dev/null
+++ b/posthog/plugins/test/test_plugins.py
@@ -0,0 +1,351 @@
+import base64
+from typing import Any, Dict
+
+from django.utils.timezone import now
+
+from posthog.api.test.base import BaseTest
+from posthog.models import Event, Person, Plugin, PluginConfig
+from posthog.plugins import Plugins
+from posthog.tasks.process_event import process_event
+
+from .plugin_archives import (
+ BROKEN_REQUIREMENTS_TXT,
+ HELLO_WORLD_PLUGIN,
+ INIT_EXCEPTION,
+ INIT_SYNTAX,
+ NO_INIT_PY,
+ NO_PLUGIN_JSON,
+ NO_REQUIREMENTS_TXT,
+ RAISE_EVENT,
+ RAISE_INSTANCE_INIT,
+ RAISE_TEAM_INIT,
+ TEST_TEAM_INSTANCE_INIT,
+)
+
+# TODO: tests to write
+# - broken tag
+# - broken zip
+# - install with broken json
+# - load from filesystem
+# - no requirements.txt in local path
+# - filtering out events by not returing them
+# - cache in plugins
+# - requirements are loaded
+
+
+class TestPlugins(BaseTest):
+ def _create_event(self, properties: Dict[str, Any] = {"whatever": "true"}):
+ process_event(
+ "plugin_test_distinct_id",
+ "",
+ "",
+ {"event": "$pageview", "properties": properties.copy(),},
+ self.team.pk,
+ now().isoformat(),
+ now().isoformat(),
+ )
+
+ def _create_plugin(self, TEMPLATE):
+ return Plugin.objects.create(
+ name="helloworldplugin",
+ description="Hello World Plugin that runs in test mode",
+ url="https://github.com/PostHog/helloworldplugin",
+ config_schema={
+ "bar": {"name": "What's in the bar?", "type": "string", "default": "baz", "required": False}
+ },
+ tag=TEMPLATE[0],
+ archive=base64.b64decode(TEMPLATE[1]),
+ from_web=True,
+ from_json=False,
+ )
+
+ def test_load_plugin(self):
+ Person.objects.create(team=self.team, distinct_ids=["plugin_test_distinct_id"])
+
+ self._create_event()
+
+ event = Event.objects.get()
+ self.assertEqual(event.event, "$pageview")
+ self.assertEqual(event.properties.get("bar", None), None)
+
+ plugin = self._create_plugin(HELLO_WORLD_PLUGIN)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=0, config={"bar": "foo"},
+ )
+
+ self.assertEqual(Plugin.objects.count(), 1)
+ self.assertEqual(PluginConfig.objects.count(), 1)
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 2)
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(events[1].properties.get("hello", None), "world")
+ self.assertEqual(events[1].properties.get("bar", None), "foo")
+
+ plugin_config.config["bar"] = "foobar"
+ plugin_config.save()
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 3)
+ self.assertEqual(events[2].properties.get("bar", None), "foobar")
+
+ plugin_config.delete()
+ plugin.delete()
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 4)
+ self.assertEqual(events[3].properties.get("bar", None), None)
+
+ def test_load_global_plugin(self):
+ Person.objects.create(team=self.team, distinct_ids=["plugin_test_distinct_id"])
+
+ self._create_event()
+
+ event = Event.objects.get()
+ self.assertEqual(event.event, "$pageview")
+ self.assertEqual(event.properties.get("bar", None), None)
+
+ plugin = self._create_plugin(HELLO_WORLD_PLUGIN)
+ plugin_config = PluginConfig.objects.create(
+ team=None, plugin=plugin, enabled=True, order=0, config={"bar": "foo"},
+ )
+
+ self.assertEqual(Plugin.objects.count(), 1)
+ self.assertEqual(PluginConfig.objects.count(), 1)
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 2)
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(events[1].properties.get("hello", None), "world")
+ self.assertEqual(events[1].properties.get("bar", None), "foo")
+
+ plugin_config.config["bar"] = "foobar"
+ plugin_config.save()
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 3)
+ self.assertEqual(events[2].properties.get("bar", None), "foobar")
+
+ plugin_config.delete()
+ plugin.delete()
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 4)
+ self.assertEqual(events[3].properties.get("bar", None), None)
+
+ def test_global_plugin_takes_precedence(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ Person.objects.create(team=self.team, distinct_ids=["plugin_test_distinct_id"])
+
+ self._create_event()
+
+ event = Event.objects.get()
+ self.assertEqual(event.event, "$pageview")
+ self.assertEqual(event.properties.get("bar", None), None)
+
+ plugin = self._create_plugin(HELLO_WORLD_PLUGIN)
+ self.assertEqual(Plugin.objects.count(), 1)
+ local_plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ global_plugin_config = PluginConfig.objects.create(
+ team=None, plugin=plugin, enabled=True, order=1, config={"bar": "foo_global"},
+ )
+
+ self.assertEqual(Plugin.objects.count(), 1)
+ self.assertEqual(PluginConfig.objects.count(), 2)
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 2)
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(events[1].properties.get("hello", None), "world")
+ self.assertEqual(events[1].properties.get("bar", None), "foo_global")
+
+ local_plugin_config.delete()
+
+ Plugins().reload_plugins()
+
+ self._create_event()
+
+ events = Event.objects.all()
+ self.assertEqual(len(events), 3)
+ self.assertEqual(events[2].properties.get("bar", None), "foo_global")
+
+ def test_broken_requirements_txt(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(BROKEN_REQUIREMENTS_TXT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(
+ Plugin.objects.get().error["message"], "Error installing requirement: !@!@#!@#!@#!@marshmallow==3.8.0"
+ )
+ self.assertEqual(Plugin.objects.get().error.get("exception", None), None)
+
+ def test_no_requirements_txt(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(NO_REQUIREMENTS_TXT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), "world")
+ self.assertEqual(events[0].properties.get("bar", None), "foo_local")
+ self.assertEqual(Plugin.objects.get().error, None)
+
+ def test_no_plugin_json(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(NO_PLUGIN_JSON)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), "world")
+ self.assertEqual(events[0].properties.get("bar", None), "foo_local")
+ self.assertEqual(Plugin.objects.get().error, None)
+
+ def test_no_init_py(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(NO_INIT_PY)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(
+ Plugin.objects.get().error["message"], "Could not find __init__.py from the plugin zip archive"
+ )
+ self.assertEqual(
+ Plugin.objects.get().error["exception"],
+ "can't find module 'helloworldplugin-2dfff8b5d6053d815ab4b555b1be4282857bd2a4'",
+ )
+
+ def test_init_exception(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(INIT_EXCEPTION)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(Plugin.objects.get().error["message"], "Error initializing __init__.py")
+ self.assertEqual(Plugin.objects.get().error["exception"], "division by zero")
+
+ def test_init_syntax(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(INIT_SYNTAX)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(Plugin.objects.get().error["message"], "Error initializing __init__.py")
+ self.assertEqual(Plugin.objects.get().error["exception"], "expected an indented block (__init__.py, line 6)")
+
+ def test_team_instance_init(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(TEST_TEAM_INSTANCE_INIT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("instance", None), "world")
+ self.assertEqual(events[0].properties.get("team", None), "hello")
+ self.assertEqual(Plugin.objects.get().error, None)
+
+ def test_raise_event(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(RAISE_EVENT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(Plugin.objects.get().error, None)
+ self.assertEqual(PluginConfig.objects.get().error["message"], "Error running method 'process_event'")
+ self.assertEqual(PluginConfig.objects.get().error["exception"], "this is fine")
+
+ def test_raise_instance_init(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(RAISE_INSTANCE_INIT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(
+ Plugin.objects.get().error["message"], 'Error running instance_init() on plugin "helloworldplugin"'
+ )
+ self.assertEqual(Plugin.objects.get().error["exception"], "Something fishy")
+
+ def test_raise_team_init(self):
+ self.assertEqual(Plugin.objects.count(), 0)
+ plugin = self._create_plugin(RAISE_TEAM_INIT)
+ plugin_config = PluginConfig.objects.create(
+ team=self.team, plugin=plugin, enabled=True, order=2, config={"bar": "foo_local"},
+ )
+ Plugins().reload_plugins()
+ self._create_event()
+ events = Event.objects.all()
+ self.assertEqual(events[0].properties.get("hello", None), None)
+ self.assertEqual(events[0].properties.get("bar", None), None)
+ self.assertEqual(Plugin.objects.get().error, None)
+ self.assertEqual(PluginConfig.objects.get().error["message"], "Error loading plugin")
+ self.assertEqual(PluginConfig.objects.get().error["exception"], "Something fishy in this team")
diff --git a/posthog/plugins/test/test_sync.py b/posthog/plugins/test/test_sync.py
new file mode 100644
index 0000000000000..bdaad5d8861eb
--- /dev/null
+++ b/posthog/plugins/test/test_sync.py
@@ -0,0 +1,379 @@
+import base64
+import io
+import json
+import os
+import tempfile
+import zipfile
+from contextlib import contextmanager
+from unittest import mock
+
+from posthog.api.test.base import BaseTest
+from posthog.models import Plugin, PluginConfig
+from posthog.plugins.sync import sync_global_plugin_config, sync_posthog_json_plugins
+
+from .plugin_archives import HELLO_WORLD_PLUGIN
+
+
+@contextmanager
+def plugins_in_posthog_json(plugins):
+ filename = None
+ try:
+ fd, filename = tempfile.mkstemp(prefix="posthog-", suffix=".json")
+ os.write(fd, str.encode(json.dumps({"plugins": plugins})))
+ os.close(fd)
+ yield filename
+ finally:
+ if filename:
+ os.unlink(filename)
+
+
+@contextmanager
+def extracted_base64_zip(base64_archive):
+ tmp_folder = None
+ try:
+ zip_file = zipfile.ZipFile(io.BytesIO(base64.b64decode(base64_archive)), "r")
+ zip_root_folder = zip_file.namelist()[0]
+ tmp_folder = tempfile.TemporaryDirectory()
+ zip_file.extractall(path=tmp_folder.name)
+ yield os.path.join(tmp_folder.name, zip_root_folder)
+ finally:
+ if tmp_folder:
+ tmp_folder.cleanup()
+
+
+# This method will be used by the mock to replace requests.get
+def mocked_requests_get(*args, **kwargs):
+ class MockResponse:
+ def __init__(self, base64_data, status_code):
+ self.content = base64.b64decode(base64_data)
+ self.status_code = status_code
+
+ def ok(self):
+ return self.status_code < 300
+
+ if args[0] == "https://github.com/PostHog/helloworldplugin/archive/{}.zip".format(HELLO_WORLD_PLUGIN[0]):
+ return MockResponse(HELLO_WORLD_PLUGIN[1], 200)
+
+ return MockResponse(None, 404)
+
+
+@mock.patch("requests.get", side_effect=mocked_requests_get)
+class TestPluginsSync(BaseTest):
+ def _write_json_plugins(self, plugins):
+ fd, json_path = tempfile.mkstemp(prefix="posthog-", suffix=".json")
+ os.write(fd, str.encode(json.dumps({"plugins": plugins})))
+ os.close(fd)
+ return json_path
+
+ def test_load_plugin_local(self, mock_get):
+ with extracted_base64_zip(HELLO_WORLD_PLUGIN[1]) as plugin_path:
+ self.assertEqual(len(Plugin.objects.all()), 0)
+
+ with plugins_in_posthog_json([{"name": "helloworldplugin", "path": plugin_path,}]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "file:{}".format(plugin_path))
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, False)
+ self.assertEqual(plugin.archive, None)
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+ self.assertEqual(plugin.tag, "")
+
+ with plugins_in_posthog_json([]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+
+ self.assertEqual(len(Plugin.objects.all()), 0)
+
+ def test_load_plugin_local_if_exists_from_app(self, mock_get):
+ with extracted_base64_zip(HELLO_WORLD_PLUGIN[1]) as plugin_path:
+ Plugin.objects.create(
+ name="helloworldplugin",
+ description="BAD DESCRIPTION",
+ url="file:{}".format(plugin_path),
+ config_schema={},
+ tag="",
+ from_web=True,
+ from_json=False,
+ )
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ with plugins_in_posthog_json([{"name": "helloworldplugin", "path": plugin_path,}]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "file:{}".format(plugin_path))
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, True)
+ self.assertEqual(plugin.archive, None)
+ self.assertEqual(plugin.tag, "")
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ with plugins_in_posthog_json([]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "file:{}".format(plugin_path))
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, False)
+ self.assertEqual(plugin.from_web, True)
+ self.assertEqual(plugin.archive, None)
+ self.assertEqual(plugin.tag, "")
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ def test_load_plugin_http(self, mock_get):
+ self.assertEqual(len(Plugin.objects.all()), 0)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "https://github.com/PostHog/helloworldplugin/")
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, False)
+ self.assertEqual(bytes(plugin.archive), base64.b64decode(HELLO_WORLD_PLUGIN[1]))
+ self.assertEqual(plugin.tag, HELLO_WORLD_PLUGIN[0])
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ with plugins_in_posthog_json([]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+
+ self.assertEqual(len(Plugin.objects.all()), 0)
+
+ def test_load_plugin_http_if_exists_from_app(self, mock_get):
+ Plugin.objects.create(
+ name="helloworldplugin",
+ description="BAD DESCRIPTION",
+ url="https://github.com/PostHog/helloworldplugin/",
+ config_schema={},
+ tag="BAD TAG",
+ archive=bytes("blabla".encode("utf-8")),
+ from_web=True,
+ from_json=False,
+ )
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "https://github.com/PostHog/helloworldplugin/")
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, True)
+ self.assertEqual(bytes(plugin.archive), base64.b64decode(HELLO_WORLD_PLUGIN[1]))
+ self.assertEqual(plugin.tag, HELLO_WORLD_PLUGIN[0])
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ with plugins_in_posthog_json([]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "https://github.com/PostHog/helloworldplugin/")
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, False)
+ self.assertEqual(plugin.from_web, True)
+ self.assertEqual(bytes(plugin.archive), base64.b64decode(HELLO_WORLD_PLUGIN[1]))
+ self.assertEqual(plugin.tag, HELLO_WORLD_PLUGIN[0])
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ def test_load_plugin_local_to_http_and_back(self, mock_get):
+ with extracted_base64_zip(HELLO_WORLD_PLUGIN[1]) as plugin_path:
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "https://github.com/PostHog/helloworldplugin/")
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, False)
+ self.assertEqual(bytes(plugin.archive), base64.b64decode(HELLO_WORLD_PLUGIN[1]))
+ self.assertEqual(plugin.tag, HELLO_WORLD_PLUGIN[0])
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ with plugins_in_posthog_json([{"name": "helloworldplugin", "path": plugin_path,}]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "file:{}".format(plugin_path))
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, False)
+ self.assertEqual(plugin.archive, None)
+ self.assertEqual(plugin.tag, "")
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+
+ plugin = Plugin.objects.get()
+ self.assertEqual(plugin.name, "helloworldplugin")
+ self.assertEqual(plugin.url, "https://github.com/PostHog/helloworldplugin/")
+ self.assertEqual(plugin.description, "Greet the World and Foo a Bar")
+ self.assertEqual(plugin.from_json, True)
+ self.assertEqual(plugin.from_web, False)
+ self.assertEqual(bytes(plugin.archive), base64.b64decode(HELLO_WORLD_PLUGIN[1]))
+ self.assertEqual(plugin.tag, HELLO_WORLD_PLUGIN[0])
+ self.assertEqual(plugin.config_schema["bar"]["type"], "string")
+
+ def test_sync_global_config(self, mock_get):
+ self.assertEqual(len(Plugin.objects.all()), 0)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ self.assertEqual(len(PluginConfig.objects.all()), 0)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ "global": {"enabled": True, "order": 2, "config": {"bar": "foo"}},
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ self.assertEqual(len(PluginConfig.objects.all()), 1)
+ plugin_config = PluginConfig.objects.get()
+ self.assertEqual(plugin_config.team, None)
+ self.assertEqual(plugin_config.plugin, Plugin.objects.get())
+ self.assertEqual(plugin_config.enabled, True)
+ self.assertEqual(plugin_config.config["bar"], "foo")
+ self.assertEqual(plugin_config.order, 2)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ "global": {"enabled": False, "order": 3, "config": {"bar": "foop"}},
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ self.assertEqual(len(PluginConfig.objects.all()), 1)
+ plugin_config = PluginConfig.objects.get()
+ self.assertEqual(plugin_config.team, None)
+ self.assertEqual(plugin_config.plugin, Plugin.objects.get())
+ self.assertEqual(plugin_config.enabled, False)
+ self.assertEqual(plugin_config.config["bar"], "foop")
+ self.assertEqual(plugin_config.order, 3)
+
+ with plugins_in_posthog_json([]) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 0)
+ self.assertEqual(len(PluginConfig.objects.all()), 0)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ "global": {"enabled": False, "order": 3, "config": {"bar": "foop"}},
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ self.assertEqual(len(PluginConfig.objects.all()), 1)
+ plugin_config = PluginConfig.objects.get()
+ self.assertEqual(plugin_config.team, None)
+ self.assertEqual(plugin_config.plugin, Plugin.objects.get())
+ self.assertEqual(plugin_config.enabled, False)
+ self.assertEqual(plugin_config.config["bar"], "foop")
+ self.assertEqual(plugin_config.order, 3)
+
+ with plugins_in_posthog_json(
+ [
+ {
+ "name": "helloworldplugin",
+ "url": "https://github.com/PostHog/helloworldplugin/",
+ "tag": HELLO_WORLD_PLUGIN[0],
+ }
+ ]
+ ) as filename:
+ sync_posthog_json_plugins(raise_errors=True, filename=filename)
+ sync_global_plugin_config(filename=filename)
+ self.assertEqual(len(Plugin.objects.all()), 1)
+ self.assertEqual(len(PluginConfig.objects.all()), 0)
diff --git a/posthog/plugins/utils.py b/posthog/plugins/utils.py
new file mode 100644
index 0000000000000..df6765eda57c4
--- /dev/null
+++ b/posthog/plugins/utils.py
@@ -0,0 +1,35 @@
+import io
+import json
+import os
+import re
+import zipfile
+
+import requests
+
+
+def download_plugin_github_zip(repo: str, tag: str):
+ url_template = "{repo}/archive/{tag}.zip"
+ url = url_template.format(repo=re.sub("/$", "", repo), tag=tag)
+ response = requests.get(url)
+ if not response.ok:
+ raise Exception("Could not download archive from GitHub")
+ return response.content
+
+
+def load_json_file(filename: str):
+ try:
+ with open(filename, "r") as reader:
+ return json.loads(reader.read())
+ except FileNotFoundError:
+ return None
+
+
+def load_json_zip_bytes(archive: bytes, filename: str):
+ zip_file = zipfile.ZipFile(io.BytesIO(archive), "r")
+ zip_root_folder = zip_file.namelist()[0]
+ file_path = os.path.join(zip_root_folder, filename)
+ try:
+ with zip_file.open(file_path) as reader:
+ return json.loads(reader.read())
+ except KeyError:
+ return None
diff --git a/posthog/settings.py b/posthog/settings.py
index 85f39c88e250e..c4795251c6c38 100644
--- a/posthog/settings.py
+++ b/posthog/settings.py
@@ -68,6 +68,9 @@ def print_warning(warning_lines: Sequence[str]):
else:
JS_URL = os.environ.get("JS_URL", "")
+INSTALL_PLUGINS_FROM_WEB = get_bool_from_env("INSTALL_PLUGINS_FROM_WEB", True)
+CONFIGURE_PLUGINS_FROM_WEB = INSTALL_PLUGINS_FROM_WEB or get_bool_from_env("CONFIGURE_PLUGINS_FROM_WEB", True)
+
# This is set as a cross-domain cookie with a random value.
# Its existence is used by the toolbar to see that we are logged in.
TOOLBAR_COOKIE_NAME = "phtoolbar"
diff --git a/posthog/tasks/process_event.py b/posthog/tasks/process_event.py
index c7f78d25dea03..68df5f944d2e8 100644
--- a/posthog/tasks/process_event.py
+++ b/posthog/tasks/process_event.py
@@ -10,6 +10,7 @@
from sentry_sdk import capture_exception
from posthog.models import Element, Event, Person, Team, User
+from posthog.plugins import Plugins, PosthogEvent
def _alias(previous_distinct_id: str, distinct_id: str, team_id: int, retry_if_failed: bool = True,) -> None:
@@ -220,13 +221,28 @@ def process_event(
_set_is_identified(team_id=team_id, distinct_id=distinct_id)
properties = data.get("properties", data.get("$set", {}))
+ event = data["event"]
- _capture(
+ plugin_event = PosthogEvent(
ip=ip,
site_url=site_url,
team_id=team_id,
- event=data["event"],
+ event=event,
distinct_id=distinct_id,
properties=properties,
timestamp=handle_timestamp(data, now, sent_at),
)
+
+ plugins = Plugins()
+ event = plugins.exec_plugins(plugin_event, team_id)
+
+ if event:
+ _capture(
+ ip=event.ip,
+ site_url=event.site_url,
+ team_id=event.team_id,
+ event=event.event,
+ distinct_id=event.distinct_id,
+ properties=event.properties,
+ timestamp=handle_timestamp(data, now, sent_at),
+ )
diff --git a/posthog/utils.py b/posthog/utils.py
index a8a0b805d2d9f..7b928be272bfa 100644
--- a/posthog/utils.py
+++ b/posthog/utils.py
@@ -9,7 +9,7 @@
import time
import uuid
from datetime import date
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
from urllib.parse import urljoin, urlparse
import lzstring # type: ignore
@@ -25,6 +25,8 @@
from rest_framework.exceptions import APIException
from sentry_sdk import capture_exception, push_scope
+from posthog.cache import get_redis_instance
+
def absolute_uri(url: Optional[str] = None) -> str:
"""
@@ -282,10 +284,8 @@ def generate_cache_key(stringified: str) -> str:
def get_redis_heartbeat() -> Union[str, int]:
-
- if settings.REDIS_URL:
- redis_instance = redis.from_url(settings.REDIS_URL, db=0)
- else:
+ redis_instance = get_redis_instance()
+ if not redis_instance:
return "offline"
last_heartbeat = redis_instance.get("POSTHOG_HEARTBEAT") if redis_instance else None
@@ -407,11 +407,9 @@ def is_redis_alive() -> bool:
return False
-def get_redis_info() -> dict:
- redis_instance = redis.from_url(settings.REDIS_URL, db=0)
- return redis_instance.info()
+def get_redis_info() -> Mapping[str, Any]:
+ return get_redis_instance().info()
def get_redis_queue_depth() -> int:
- redis_instance = redis.from_url(settings.REDIS_URL, db=0)
- return redis_instance.llen("celery")
+ return get_redis_instance().llen("celery")
diff --git a/requirements.txt b/requirements.txt
index f9d7ee9a072bb..b516763e76b9f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -65,6 +65,7 @@ pytz==2019.3
redis==3.4.1
requests==2.22.0
requests-oauthlib==1.3.0
+pip-tools==5.0.0
ruamel.yaml==0.16.10
ruamel.yaml.clib==0.2.0
sentry-sdk==0.16.5
@@ -76,11 +77,8 @@ sqlparse==0.3.0
tenacity==6.1.0
toronado==0.0.11
traitlets==4.3.3
-typing==3.7.4.3
-typing-extensions==3.7.4.2
uritemplate==3.0.1
urllib3==1.25.8
-uuid==1.30
vine==1.3.0
wcwidth==0.1.9
Werkzeug==1.0.0
diff --git a/yarn.lock b/yarn.lock
index 07f2c1a579b60..91d261624347c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1157,9 +1157,9 @@
csstype "^3.0.2"
"@types/sinonjs__fake-timers@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
- integrity sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
+ integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
"@types/sizzle@^2.3.2":
version "2.3.2"
@@ -1485,7 +1485,7 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2:
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
-ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4:
+ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.4:
version "6.12.4"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234"
integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==
@@ -1495,6 +1495,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
+ajv@^6.12.3:
+ version "6.12.6"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+ integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+ dependencies:
+ fast-deep-equal "^3.1.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.4.1"
+ uri-js "^4.2.2"
+
alphanum-sort@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -3933,10 +3943,10 @@ eslint-config-prettier@^6.11.0:
dependencies:
get-stdin "^6.0.0"
-eslint-plugin-cypress@^2.11.1:
- version "2.11.1"
- resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862"
- integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ==
+eslint-plugin-cypress@^2.11.2:
+ version "2.11.2"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.2.tgz#a8f3fe7ec840f55e4cea37671f93293e6c3e76a0"
+ integrity sha512-1SergF1sGbVhsf7MYfOLiBhdOg6wqyeV9pXUAIDIffYTGMN3dTBQS9nFAzhLsHhO+Bn0GaVM1Ecm71XUidQ7VA==
dependencies:
globals "^11.12.0"
@@ -5830,10 +5840,10 @@ kea-router@^0.4.0:
dependencies:
url-pattern "^1.0.3"
-kea-typegen@^0.3.0:
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/kea-typegen/-/kea-typegen-0.3.0.tgz#fa15842f11c889a49231ad665e7b9682697eea05"
- integrity sha512-Ch5AhNzYvYrehBOsC4SfYw9oav50I7hQw5B6eAJnulkQpd625rvS86EVsTncLRDIEUUUmywkTGGKOQs7ii59Cg==
+kea-typegen@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/kea-typegen/-/kea-typegen-0.3.2.tgz#2ff669627af430b8c5fe15a0b4dd02341c4cd37e"
+ integrity sha512-AGSox31u/esOkYwOvD/RATE/WJvl62ximPWGWCMPxNol+k8nItqYPApIz6rOboRHtooGrKNn50s9NSbAZFgq8Q==
dependencies:
"@wessberg/ts-clone-node" "0.3.8"
prettier "^2.0.5"
@@ -6415,11 +6425,16 @@ mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.5"
-moment@^2.10.2, moment@^2.24.0, moment@^2.25.3, moment@^2.27.0:
+moment@^2.10.2, moment@^2.24.0, moment@^2.25.3:
version "2.27.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
+moment@^2.27.0:
+ version "2.29.1"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
+ integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
+
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -8661,7 +8676,14 @@ rw@1:
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
-rxjs@^6.3.3, rxjs@^6.5.2, rxjs@^6.6.2:
+rxjs@^6.3.3:
+ version "6.6.3"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552"
+ integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==
+ dependencies:
+ tslib "^1.9.0"
+
+rxjs@^6.5.2, rxjs@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==