-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(plugin-server): Remove Postgres-based plugin error logging in favor of existing ClickHouse-based approaches #18764
Changes from 20 commits
b9c9336
c4cbd65
b71ea99
8d2a31a
82f4f16
8068ec3
684c764
29f5f99
182130c
ff5ce59
8f28d38
b30bb45
92ab3e8
b229260
7e3d94d
185d834
afbe6b7
ff4d6e9
9124858
e96da9d
b355cfe
4156eba
2a54885
3392e15
b6a4107
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
import { v4 as uuid4 } from 'uuid' | ||
|
||
import { ONE_HOUR } from '../src/config/constants' | ||
import { PluginLogEntryType } from '../src/types' | ||
import { UUIDT } from '../src/utils/utils' | ||
import { | ||
capture, | ||
|
@@ -12,7 +13,9 @@ import { | |
createTeam, | ||
enablePluginConfig, | ||
fetchEvents, | ||
fetchPluginAppMetrics, | ||
fetchPluginConsoleLogEntries, | ||
fetchPluginLogEntries, | ||
fetchPostgresPersons, | ||
getPluginConfig, | ||
reloadPlugins, | ||
|
@@ -88,133 +91,119 @@ test.concurrent(`plugin method tests: event captured, processed, ingested`, asyn | |
}) | ||
}) | ||
|
||
test.concurrent(`plugin method tests: creates error on unhandled throw`, async () => { | ||
const plugin = await createPlugin({ | ||
organization_id: organizationId, | ||
name: 'test plugin', | ||
plugin_type: 'source', | ||
is_global: false, | ||
source__index_ts: ` | ||
test.concurrent( | ||
`plugin method tests: records error in app metrics and creates log entry on unhandled throw`, | ||
async () => { | ||
const plugin = await createPlugin({ | ||
organization_id: organizationId, | ||
name: 'test plugin', | ||
plugin_type: 'source', | ||
is_global: false, | ||
source__index_ts: ` | ||
export async function processEvent(event) { | ||
throw new Error('error thrown in plugin') | ||
} | ||
`, | ||
}) | ||
const teamId = await createTeam(organizationId) | ||
const pluginConfig = await createAndReloadPluginConfig(teamId, plugin.id) | ||
|
||
const distinctId = new UUIDT().toString() | ||
const uuid = new UUIDT().toString() | ||
}) | ||
const teamId = await createTeam(organizationId) | ||
const pluginConfig = await createAndReloadPluginConfig(teamId, plugin.id) | ||
|
||
const event = { | ||
event: 'custom event', | ||
// NOTE: Before `sanitizeJsonbValue` was added, the null byte below would blow up the error | ||
// UPDATE, breaking this test. It is now replaced with the Unicode replacement character, | ||
// \uFFFD. | ||
properties: { name: 'haha', other: '\u0000' }, | ||
} | ||
const distinctId = new UUIDT().toString() | ||
const uuid = new UUIDT().toString() | ||
|
||
await capture({ teamId, distinctId, uuid, event: event.event, properties: event.properties }) | ||
const event = { | ||
event: 'custom event', | ||
properties: { name: 'haha', other: '\u0000' }, | ||
} | ||
|
||
await waitForExpect(async () => { | ||
const events = await fetchEvents(teamId) | ||
expect(events.length).toBe(1) | ||
return events | ||
}) | ||
await capture({ teamId, distinctId, uuid, event: event.event, properties: event.properties }) | ||
|
||
const error = await waitForExpect(async () => { | ||
const pluginConfigAgain = await getPluginConfig(teamId, pluginConfig.id) | ||
expect(pluginConfigAgain.error).not.toBeNull() | ||
return pluginConfigAgain.error | ||
}) | ||
await waitForExpect(async () => { | ||
const events = await fetchEvents(teamId) | ||
expect(events.length).toBe(1) | ||
return events | ||
}) | ||
|
||
expect(error.message).toEqual('error thrown in plugin') | ||
const errorProperties = error.event.properties | ||
expect(errorProperties.name).toEqual('haha') | ||
expect(errorProperties.other).toEqual('\uFFFD') | ||
}) | ||
const appMetric = await waitForExpect(async () => { | ||
const errorMetrics = await fetchPluginAppMetrics(pluginConfig.id) | ||
expect(errorMetrics.length).toEqual(1) | ||
return errorMetrics[0] | ||
}) | ||
|
||
test.concurrent(`plugin method tests: creates error on unhandled rejection`, async () => { | ||
const plugin = await createPlugin({ | ||
organization_id: organizationId, | ||
name: 'test plugin', | ||
plugin_type: 'source', | ||
is_global: false, | ||
source__index_ts: ` | ||
export async function processEvent(event) { | ||
void new Promise((_, rejects) => { rejects(new Error('error thrown in plugin')) }).then(() => {}) | ||
return event | ||
} | ||
Comment on lines
-146
to
-149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding of the |
||
`, | ||
}) | ||
const teamId = await createTeam(organizationId) | ||
const pluginConfig = await createAndReloadPluginConfig(teamId, plugin.id) | ||
expect(appMetric.successes).toEqual(0) | ||
expect(appMetric.failures).toEqual(1) | ||
expect(appMetric.error_type).toEqual('Error') | ||
expect(JSON.parse(appMetric.error_details!)).toMatchObject({ | ||
error: { message: 'error thrown in plugin' }, | ||
event: { properties: event.properties }, | ||
}) | ||
Comment on lines
+129
to
+141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've learned to really like doing a single check, because then if the test fails I see directly what it was instead and it's quicker for me to know what broke, i.e.
Does this make sense? Not expecting you to change an already merged PR more for maybe let's do this in the future. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, that makes sense! (I do need to spend some time getting more familiar with the different There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure my code there is valid btw - I let copilot write it for me normally 😅 |
||
|
||
const distinctId = new UUIDT().toString() | ||
const uuid = new UUIDT().toString() | ||
const errorLogEntry = await waitForExpect(async () => { | ||
const errorLogEntries = (await fetchPluginLogEntries(pluginConfig.id)).filter( | ||
(entry) => entry.type == PluginLogEntryType.Error | ||
) | ||
expect(errorLogEntries.length).toBe(1) | ||
return errorLogEntries[0] | ||
}) | ||
|
||
const event = { | ||
event: 'custom event', | ||
properties: { name: 'haha' }, | ||
expect(errorLogEntry.message).toContain('error thrown in plugin') | ||
} | ||
) | ||
|
||
await capture({ teamId, distinctId, uuid, event: event.event, properties: event.properties }) | ||
|
||
await waitForExpect(async () => { | ||
const events = await fetchEvents(teamId) | ||
expect(events.length).toBe(1) | ||
return events | ||
}) | ||
|
||
const error = await waitForExpect(async () => { | ||
const pluginConfigAgain = await getPluginConfig(teamId, pluginConfig.id) | ||
expect(pluginConfigAgain.error).not.toBeNull() | ||
return pluginConfigAgain.error | ||
}) | ||
|
||
expect(error.message).toEqual('error thrown in plugin') | ||
}) | ||
|
||
test.concurrent(`plugin method tests: creates error on unhandled promise errors`, async () => { | ||
const plugin = await createPlugin({ | ||
organization_id: organizationId, | ||
name: 'test plugin', | ||
plugin_type: 'source', | ||
is_global: false, | ||
source__index_ts: ` | ||
test.concurrent( | ||
`plugin method tests: records success in app metrics and creates error log entry on unawaited promise rejection`, | ||
async () => { | ||
const plugin = await createPlugin({ | ||
organization_id: organizationId, | ||
name: 'test plugin', | ||
plugin_type: 'source', | ||
is_global: false, | ||
source__index_ts: ` | ||
export async function processEvent(event) { | ||
void new Promise(() => { throw new Error('error thrown in plugin') }).then(() => {}) | ||
void new Promise(() => { throw new Error('error thrown in plugin') }) | ||
return event | ||
} | ||
`, | ||
}) | ||
const teamId = await createTeam(organizationId) | ||
const pluginConfig = await createAndReloadPluginConfig(teamId, plugin.id) | ||
}) | ||
const teamId = await createTeam(organizationId) | ||
const pluginConfig = await createAndReloadPluginConfig(teamId, plugin.id) | ||
|
||
const distinctId = new UUIDT().toString() | ||
const uuid = new UUIDT().toString() | ||
const distinctId = new UUIDT().toString() | ||
const uuid = new UUIDT().toString() | ||
|
||
const event = { | ||
event: 'custom event', | ||
properties: { name: 'haha' }, | ||
} | ||
const event = { | ||
event: 'custom event', | ||
properties: { name: 'haha' }, | ||
} | ||
|
||
await capture({ teamId, distinctId, uuid, event: event.event, properties: event.properties }) | ||
await capture({ teamId, distinctId, uuid, event: event.event, properties: event.properties }) | ||
|
||
await waitForExpect(async () => { | ||
const events = await fetchEvents(teamId) | ||
expect(events.length).toBe(1) | ||
return events | ||
}) | ||
await waitForExpect(async () => { | ||
const events = await fetchEvents(teamId) | ||
expect(events.length).toBe(1) | ||
return events | ||
}) | ||
|
||
const error = await waitForExpect(async () => { | ||
const pluginConfigAgain = await getPluginConfig(teamId, pluginConfig.id) | ||
expect(pluginConfigAgain.error).not.toBeNull() | ||
return pluginConfigAgain.error | ||
}) | ||
const appMetric = await waitForExpect(async () => { | ||
const appMetrics = await fetchPluginAppMetrics(pluginConfig.id) | ||
expect(appMetrics.length).toEqual(1) | ||
return appMetrics[0] | ||
}) | ||
|
||
expect(error.message).toEqual('error thrown in plugin') | ||
}) | ||
expect(appMetric.successes).toEqual(1) | ||
expect(appMetric.failures).toEqual(0) | ||
|
||
const errorLogEntry = await waitForExpect(async () => { | ||
const errorLogEntries = (await fetchPluginLogEntries(pluginConfig.id)).filter( | ||
(entry) => entry.type == PluginLogEntryType.Error | ||
) | ||
expect(errorLogEntries.length).toBe(1) | ||
return errorLogEntries[0] | ||
}) | ||
|
||
expect(errorLogEntry.message).toContain('error thrown in plugin') | ||
} | ||
Comment on lines
+189
to
+207
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a subtle distinction here that I hadn't originally considered until making this change: it's possible for a plugin execution to succeed (and be marked as such in app metrics), but also log an error at some later time in a case similar to this one where an un-awaited promise is later rejected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All plugins will be non-async in the future ... that's my current plan at least. |
||
) | ||
|
||
test.concurrent(`plugin method tests: teardown is called on stateful plugin reload if they are updated`, async () => { | ||
const plugin = await createPlugin({ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,16 +1,5 @@ | ||
import { | ||
Hub, | ||
Plugin, | ||
PluginAttachmentDB, | ||
PluginCapabilities, | ||
PluginConfig, | ||
PluginConfigId, | ||
PluginError, | ||
PluginLogEntrySource, | ||
PluginLogEntryType, | ||
} from '../../types' | ||
import { Hub, Plugin, PluginAttachmentDB, PluginCapabilities, PluginConfig, PluginConfigId } from '../../types' | ||
import { PostgresUse } from './postgres' | ||
import { sanitizeJsonbValue } from './utils' | ||
|
||
function pluginConfigsInForceQuery(specificField?: keyof PluginConfig): string { | ||
const fields = specificField | ||
|
@@ -23,8 +12,7 @@ function pluginConfigsInForceQuery(specificField?: keyof PluginConfig): string { | |
posthog_pluginconfig.order, | ||
posthog_pluginconfig.config, | ||
posthog_pluginconfig.updated_at, | ||
posthog_pluginconfig.created_at, | ||
posthog_pluginconfig.error IS NOT NULL AS has_error | ||
posthog_pluginconfig.created_at | ||
` | ||
|
||
return `SELECT ${fields} | ||
|
@@ -117,27 +105,6 @@ export async function setPluginCapabilities( | |
) | ||
} | ||
|
||
export async function setError(hub: Hub, pluginError: PluginError | null, pluginConfig: PluginConfig): Promise<void> { | ||
await hub.db.postgres.query( | ||
PostgresUse.COMMON_WRITE, | ||
'UPDATE posthog_pluginconfig SET error = $1 WHERE id = $2', | ||
// NOTE: In theory `onEvent` shouldn't be seeing events that still have the null byte, but | ||
// it's better to be safe than sorry and sanitize the value here as well. | ||
[sanitizeJsonbValue(pluginError), typeof pluginConfig === 'object' ? pluginConfig?.id : pluginConfig], | ||
'updatePluginConfigError' | ||
) | ||
if (pluginError) { | ||
Comment on lines
-121
to
-128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the important part that was removed. (The |
||
await hub.db.queuePluginLogEntry({ | ||
pluginConfig, | ||
source: PluginLogEntrySource.Plugin, | ||
type: PluginLogEntryType.Error, | ||
message: pluginError.stack ?? pluginError.message, | ||
instanceId: hub.instanceId, | ||
timestamp: pluginError.time, | ||
}) | ||
} | ||
} | ||
|
||
export async function disablePlugin(hub: Hub, pluginConfigId: PluginConfigId): Promise<void> { | ||
await hub.db.postgres.query( | ||
PostgresUse.COMMON_WRITE, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having this here (and in
plugin-server/bin/ci_functional_tests.sh
) feels like a hack.I think the ideal approach would be to have the functional test setup/teardown control the server itself and provide any ad hoc test-specific configurations like this — but without taking the time to better increase test isolation here, I'm not sure that's worth doing. Not sure if there is a better compromise approach here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you, this smells but we can live with it a bit more, then do a spring cleaning.
BTW
PLUGINS_DEFAULT_LOG_LEVEL=0
should also be added here, as it's in the script, and needed bywaitForPluginToLoad
, could you please add it?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added this in 3392e15.