merlyn/server/models/telemetry.js
Marcello Fitton 41495cdabe
feat: Scheduled Jobs (#5322)
* initialize

* expand tool result text limit | add syntax highlighting and json formatting to tool result rendering

* fix onError jsdoc

* lint

* fix unread icon

* route protection

* improve form handling for NewJobModal

* safeJsonParse

* remove unneeded comments

* remove trycatch

* add truncateText helper

* add explicit fallback value tos safeJsonParse

* add shared cron constant and helpers

* reduce frontend indirection

* use isLight to compute syntax highlighting theme

* remove dead code

* remove forJob and make job limit to 50

* create recomputeNextRunAt helper method

* add comment about nextRunAt recomputation

* add job queue and concurrency control to scheduled jobs

* use p-queue

* change default max concurrent value to 1

* add comment explaining internal scheduling system

* add recomputeNextRunAt on boot

* add generated documents to run details

* Modify toolsOverride functionality where no tools selected means no tools are given to the agent

add a select all/deselect all toggle button for easily selecting all
tools in the cerate job form

* create usePolling hook

* add polling to scheduled jobs and scheduled job runs pages

* add cron generation feature in job form

* remove cron generation feature | add cron builder feature | add max active scheduled jobs limit

* set MAX_ACTIVE to null

* replace hour and minute input fields with input with type time

* simplify

* organize components

* move components to bottom of page component

* change Generated Documents to Generated Files

* add i18n to cronstrue

* add i18n

* add type="button" to button elements

* refactor fileSource retrieval logic

* one scheduled job run can have status "running"

* add protection of file retrieveal from scheduled job in multiuser mode

* fix comments

* make job status default to queued

* add queued status

* fix bug with result trace rendering

* store timeout ref and clearTimeout once race settles

* remove unneeded handlerPromise tracking

* move imports to top level

* refactor hardcoded paths to path resolve functions

* implement new job form design

* simplify

* fix button styles

* fix runJob bug

* implement styles for scheduled jobs page

* apply dark mode figma styles

* delete unused translation key

* implement light mode for new new job modal, run history, and run details

* lint

* fix light mode scroll bar in tool call card

* adjust table header contrast

* fix type in subtitle

* kill workers when job is in-flight before deleting job

* add border-none to buttons

* change locale time to iso string

* import BackgroundService module level | instatiate backgroundService singltone once and reuse across handlers

* add p-queue, @breejs/later and cron-validate as core deps

* parse cron expression to a builder state once

* add theme to day buttons in cron builder

* fix stale tools selection caption

* flip popover when popover clips screen height

* make ScheduleJob.trigger() await the run insertion | disable run now button if job is in flight

* regen table

* refactor generated file card

* refactor frontend

* remove logs

* major refactor for tool picking, fix bree/later bug

* combine action endpoints, move contine to method

* fix unoptimized query with include + take + order

* fix dangerous use, refactor job to utils

* add copy content to text response

* improve notification system subscription for browser

* remove unused translations

* prevent gen-file cleanup job from deleting active job file generated references

* rich text copy

* Scheduled Jobs: Translations (#5482)

* add locales for scheduled jobs

* i18n

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* add config flag with UI notice

* update README

* telemetry datapoints

* Always use UTC on backend, convert to local in frontend

* fix tz render

* Add job killing

* cleanup thinking text in job notifications and break out reasoning in response text.
Also hide zero metrics since that is useless

* Port generatedFile schema to the normalized workspace chat `outputs` file format so porting to thread is simple and implem between chats <> jobs is 1:1

* what the fuck

* compiled bug

* fixed thinking oddity in complied frontend

* supress multi-toast

* fix duration call

* Revert "fix duration call"

This reverts commit 0491bc71f4223e65ea4046561b15b268fefb8da2.

* revert and reapply fix

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-04-29 12:05:46 -07:00

149 lines
4.3 KiB
JavaScript

const { v4 } = require("uuid");
const { SystemSettings } = require("./systemSettings");
// Map of events and last sent time to check if the event is on cooldown
// This will be cleared on server restart - but that is fine since it is mostly to just
// prevent spamming the logs.
const TelemetryCooldown = new Map();
const Telemetry = {
// Write-only key. It can't read events or any of your other data, so it's safe to use in public apps.
pubkey: "phc_9qu7QLpV8L84P3vFmEiZxL020t2EqIubP7HHHxrSsqS",
stubDevelopmentEvents: true, // [DO NOT TOUCH] Core team only.
label: "telemetry_id",
/*
Key value pairs of events that should be debounced to prevent spamming the logs.
This should be used for events that could be triggered in rapid succession that are not useful to atomically log.
The value is the number of seconds to debounce the event
*/
debounced: {
sent_chat: 1800,
agent_chat_sent: 1800,
agent_chat_started: 1800,
agent_tool_call: 1800,
// Document mgmt events
document_uploaded: 30,
documents_embedded_in_workspace: 30,
link_uploaded: 30,
raw_document_uploaded: 30,
document_parsed: 30,
agent_generated_file_downloaded: 30,
},
id: async function () {
const result = await SystemSettings.get({ label: this.label });
return result?.value || null;
},
connect: async function () {
const client = this.client();
const distinctId = await this.findOrCreateId();
return { client, distinctId };
},
isDev: function () {
return process.env.NODE_ENV === "development" && this.stubDevelopmentEvents;
},
client: function () {
if (process.env.DISABLE_TELEMETRY === "true" || this.isDev()) return null;
const { PostHog } = require("posthog-node");
return new PostHog(this.pubkey);
},
runtime: function () {
if (process.env.ANYTHING_LLM_RUNTIME === "docker") return "docker";
if (process.env.NODE_ENV === "production") return "production";
return "other";
},
/**
* Checks if the event is on cooldown
* @param {string} event - The event to check
* @returns {boolean} - True if the event is on cooldown, false otherwise
*/
isOnCooldown: function (event) {
// If the event is not debounced, return false
if (!this.debounced[event]) return false;
// If the event is not in the cooldown map, return false
const lastSent = TelemetryCooldown.get(event);
if (!lastSent) return false;
// If the event is in the cooldown map, check if it has expired
const now = Date.now();
const cooldown = this.debounced[event] * 1000;
return now - lastSent < cooldown;
},
/**
* Marks the event as on cooldown - will check if the event is debounced first
* @param {string} event - The event to mark
*/
markOnCooldown: function (event) {
if (!this.debounced[event]) return;
TelemetryCooldown.set(event, Date.now());
},
sendTelemetry: async function (
event,
eventProperties = {},
subUserId = null,
silent = false
) {
try {
const { client, distinctId: systemId } = await this.connect();
if (!client) return;
const distinctId = !!subUserId ? `${systemId}::${subUserId}` : systemId;
const properties = { ...eventProperties, runtime: this.runtime() };
// If the event is on cooldown, return
if (this.isOnCooldown(event)) return;
// Silence some events to keep logs from being too messy in production
// eg: Tool calls from agents spamming the logs.
if (!silent) {
console.log(`\x1b[32m[TELEMETRY SENT]\x1b[0m`, {
event,
distinctId,
properties,
});
}
client.capture({
event,
distinctId,
properties,
});
} catch {
return;
} finally {
// Mark the event as on cooldown if needed
this.markOnCooldown(event);
}
},
flush: async function () {
const client = this.client();
if (!client) return;
await client.shutdownAsync();
},
setUid: async function () {
const newId = v4();
await SystemSettings._updateSettings({ [this.label]: newId });
return newId;
},
findOrCreateId: async function () {
let currentId = await this.id();
if (currentId) return currentId;
currentId = await this.setUid();
return currentId;
},
};
module.exports = { Telemetry };