Analytics and telemetry (PostHog)
Updated Oct 16, 2026 Open the tool
Zoovdev measures how the site and the workspace are used with PostHog (project 336183, US cloud), through a first-party proxy at /z/ph so ad blockers see the site's own domain. Nothing is sent until the visitor allows cookies; the answer is one key, cookie_consent, shared by the legacy site's banner and the workspace's consent card, and a decline turns capture off and forgets the person's id. The answer can be changed later: Cookie settings in the footer of every page outside the workspace (2026-10-02) brings the card back. Signed-in people are identified by their user id with their email and sign-up date; nothing else personal is attached to an event.
What the workspace reports (since 2026-09-19)
Before 2026-09-19 the workspace (/app) sent no page views and no product events of its own; only the plans and credits dialogs reported (the pricing_* and payment_* events shared with the site). It now reports through one module, src/lib/workspace/services/analytics.ts, and every event carries surface: workspace plus, as PostHog super properties that ride along until they change, the game (fivem, roblox), the mode (the tool on the stage), the tier (the plan), signed_in, and since 2026-10-09 display (app in the installed app's own window, browser in a tab).
Page views: the workspace is one page whose address is the tool, so every address change is a $pageview, and the first one of a visit also sends workspace_opened with the path and the referrer.
Product events, in the order a visit tends to go:
workspace_mode_opened(mode, game, section, has_project): a tool came onto the stage, whether by the rail, the address, a drop or a project.workspace_game_switched(from, to).workspace_rail_edited(2026-10-05; how, tool, pinned): the person changed which tools sit on the left rail.howis star (the Tools menu star), menu (right-click Unpin), cross (the cross on a tile while editing), drag, keys (the editor grip's arrows), editor (pin or unpin in the editor), most_used or reset;toolnames the tool when one moved;pinnedis how many tools the rail shows after. Read withworkspace_mode_openedbysectionto see whether pinning changes what people open.workspace_app_install_asked(2026-10-09; source, way): Install app pressed in the profile menu;wayis prompt (Chrome or Edge, which then ask their own question), dock (Safari on a Mac, told File › Add to Dock) or home-screen (iPhone, iPad).workspace_app_install_answered(outcome accepted | dismissed): the browser's question answered.workspace_app_installed: the browser'sappinstalled, from our row or its own install icon alike. The site bar and the front page's closing block ask too (sourcesite-nav:<surface>,closing), alongside their owncta: install_appclick./installsendsinstall_page_viewed(platform, installed) andinstall_page_clicked(platform, way), and asks withsource: install-page. Seeshell/install.svelte.tsand the workspace doc.- Paying in the Credits dialog and the plans dialog (2026-10-10; plans and boosts the same day):
payment_subscription_checkout_startedandpricing_plan_selectedcarrycheckout_mode(elements|redirect) andcheckout_destination(payment_step|stripe_checkout), the server'spayment_subscription_checkout_createdsayscheckout_mode, andpayment_elements_completedfires withplan_idandbilling_cyclefor a plan paid in the step.payment_pack_checkout_startedcarriescheckout_mode(elementsfor the payment step,redirectfor Stripe's page) andpayment_elements_completed(package_id, credit_amount, total) fires on a payment made in the step, withsource(creditsorplans) since the plans dialog took the step too. workspace_file_received(format, size_kb, forced, mode;viapicker or folder when it came through a tool's own Choose file button, which the page's drop router never sees): a file was dropped, picked or pasted. Then one ofworkspace_file_opened(the tool that took it,switchedwhen it was another tool),workspace_file_to_library(no tool takes it, the library keeps it),workspace_file_rejected, orworkspace_file_chooser_shown(more than one tool could take it).workspace_sample_used(mode, format) when a tool's sample is tried, from the conversion stage (its empty state or, since 2026-09-29, the Steps card's Try a sample).workspace_designer_link(modeclothingorprop, since 2026-09-30): Edit YDD/YDR textures under a Create tool's empty stage was pressed, someone who came to that tool for a texture.workspace_next_step_clicked(since 2026-10-06;fromuv, prop or clothing,todesign,unittextures,cost,balance,covered): the AI card after a free tool's export was pressed (the UV map written, a prop or garment converted), on its way to the Designer.workspace_reference_added(modeproporclothing, count, formatydrorydd, since 2026-09-30): the person's own reference file was added in the panel's Reference field (a YDR beside a prop in place of the person, YDD files in place of a clothing slot's base piece).workspace_asset_opened(format, kind, source, forced),workspace_asset_downloaded(format, kind, source, bundle, all),workspace_library_uploaded(count, formats).workspace_leave_answered(left, from, to): the "leave the tool" question, and which way it went. Since 2026-10-15 the question is asked only when leaving loses or stops something (workspace.md, "The one door out"), so the event is rarer and no longer a count of tool switches.webhook_created(2026-10-02): a webhook made on/app/api(Max). Made with a key over the API it is not an event.- Sync pack (2026-10-03, from the Sync dialog):
pack_sync_target_added(kindgithub or discord,mode,clean,auto),pack_sync_started(kind,release),pack_sync_folder_changed(clean),pack_build_requested(release,againwhen it had failed). Syncs after each build start on the server and are not events. workspace_plans_opened(source, section, highlight, promo, and since 2026-10-06need, the cost in credits of the run a wall refused, absent from other doors) andworkspace_credits_opened(source, balance): the two money dialogs opening; what happens inside them is the existingpricing_*andpayment_*events, plus since 2026-09-27 the promotion code box:pricing_promo_applied(promo_code, percent_off, amount_off, duration),pricing_promo_rejected(promo_code) andpricing_promo_removed; everypricing_*andpayment_*_startedevent from the dialog carriespromo_code(null without one), and the server'spayment_checkout_created/payment_subscription_checkout_createdcarrypromo_code_applied. A?promo=link opens the dialog withsource = promo-link. Since 2026-10-06:pricing_promo_rejectedcarriesquiet(true when the code was handed in by a link or a cancelled checkout and the dialog drew nothing for the refusal); a checkout left unpaid comes back assource = stripe-cancelwithpromo = BACK15on the open; the livery result dialog's Get credits issource = erlc-result(section = packs); the recovery DM's buttons arrive as?plans=…&promo=BACK15&from=recovery-dm, sosource = discover:recovery-dm; andworkspace_billing_pill_clicked(status:past_dueorunpaid) is the red Payment failed pill in the top bar on its way to the portal. Thepricing_yearly_confirm_*events are gone with the second press.sourcenames the door: the walls (convert-quota,converter-quota,optimize-quota,uv-limit, thegenerate-*and tool credit doors), and since 2026-09-20 the prompts that come before a wall (topbar-upgrade,convert-quota-low,converter-quota-low,optimize-quota-low,expiry-toast,expiry-menu,pin-limit,storage-meter,credits-plan-row,search). Since 2026-09-23 the Designer's Free limits:designer-timeanddesigner-dialog-time(the lock over the canvas, in the tool and in the Designer dialog),designer-time-lowanddesigner-dialog-time-low(the "min left today" chip),designer-export-size(the line under Save),designer-export-dialog(the DDS / YTD dialog),designer-textures(a YTD or DDS refused),designer-preview(the 3D preview lock),designer-designs(a design not saved),erlc-export-size(the locked 2048 in Liveries). Since 2026-09-29:erlc-referencesandoutfit-references(the locked Up to 4 reference tile in Liveries and Clothing, and the See plans on the toast when a picked set is past the plan's references), both withhighlight = standard. Since 2026-09-27:veil(See plans on the sign-in card a visitor sees over every tool),vehicle-limit(Free's vehicle for the day written),converter-mod-limit(Free's mod for the day built); and the credit walls (image-tool,tattoo-tool,generate-image,refine,generate-model,generate-livery,generate-clothing,packs-generate,preview-imagine, the cost table's button) open withhighlight = proandsection = plansinstead of landing on the packs. The day's counts themselves are rows inws_allowance_uses, not events.workspace_search_opened(source: button or shortcut) andworkspace_search_picked(kind: Tools, Convert, Go to or Files; id of the row,filefor any file; typed: how many characters). What was typed is never sent.workspace_pack_rendered(models, pictures, failed, size, shape, duration_ms) when a pack render in Preview ends, stopped or not. (The (app) layout, one of this doc's sources, changed on 2026-09-20 to re-run its loads after a paid subscription checkout; no event was added or changed by that.)- Code tool:
code_prompt_sent(model, chars, turn, files, has_ui),code_reply_finished(model, files_changed, files, made_ui),code_reply_failed(model, message, and since 2026-10-15wall:PROMPT_TOO_LARGE,LOW_CREDITS,NO_CREDITSor null, so the plan's walls count apart from real failures),code_rewound(messages_removed, turns, restored, deleted),code_delete_answered(deleted),code_reply_resumed(kind:retrywhen nothing came back,continuewhen part of a reply did). Shell health:workspace_body_unlocked(path) each time the page was found locked by a layer that had already closed and was let go (see workspace.md);workspace_language_changed(from, to).
Server-side events, on the person's id, so the funnel joins the browser's events:
workspace_job_created(action, mode, tier, input_format, input_source, counted, queue_active, queue_used_today) when a conversion is queued;workspace_job_completed(outputs, output_bytes, duration_ms, queued_ms, provider) andworkspace_job_failed/workspace_job_cancelled(code, retryable, duration_ms) when the worker finishes it.auth_signin_started(provider, flow, retry, next, surface; anonymous) on every sign-in door,auth_signin_completedon the callback, on the new session's id.surfaceis workspace whennextis under/app.code_chat_completed(model, requested_model, framework, steps, prompt_tokens, completion_tokens, credits, billed, has_code_access) per Syntax reply, after billing.- Already there before:
image_generation_completed/image_texture_generated(every image and texture),tattoo_generation_completed,payment_checkout_created,payment_subscription_checkout_created,payment_saved_method_charge,mesh_generation_queued,mesh_remesh_queued,conversion_credits_deducted/_refunded.
Errors: unhandled client errors are captured as $exception by the client hook; server exceptions by captureServerException. In dev the client hook also logs the error to the console (2026-09-24): SvelteKit otherwise shows only the error page, and a stale module export was invisible without it. A click on a sign-in link used to add a row as well: /auth/start is a server route with no client route, so the router first said Not found: /auth/start (a 404 through handleError, about a hundred rows a day, 1,250 people a fortnight) and only then handed the address to the server, which 303s to Discord. Since 2026-10-15 every sign-in link carries data-sveltekit-reload (ui/Button.svelte for any /auth/ address, the raw anchors in shell/Tasks.svelte and ui/SignInError.svelte), and the client hook drops a 404 whose address starts with /auth/ or /api/. The old way also landed a server auth_signin_started for every such click, so starts before that date overcount against completions.
workspace_notifications_opened(2026-10-05; unread: what the badge said) andworkspace_notification_clicked(kind;idfor an announcement, none for a personal one): the bell in the top bar (see the Notifications doc).
Ad blockers and the PostHog proxy (2026-09-21)
Ad blockers were refusing most of what PostHog sends, even through the first-party proxy at /z/ph. The proxy was never enough on its own: EasyPrivacy and uBlock Origin's own list match what a request looks like, not where it goes, and all three rules that hit us carry no domain at all.
| What was refused | The rule | List |
|---|---|---|
/z/ph/static/posthog-recorder.js |
/posthog-recorder.js |
EasyPrivacy |
/z/ph/static/dead-clicks-autocapture.js |
/dead-clicks-autocapture.js |
EasyPrivacy |
/z/ph/e/ and /z/ph/i/v0/e/ |
/e/*^ip=*^compression= |
uBlock privacy |
So session replay was dead for every visitor running a blocker, and their events never arrived either. Three changes, all on our side; PostHog itself sees exactly the addresses it always has.
- The recorder is renamed. The proxy rewrites PostHog's own remote config, swapping
"scriptConfig":{"script":"posthog-recorder"}forlazy-recorder, which is posthog-js's own default name for the same bundle and is on no list. It has to be that one and notrecorder: replay starts only when bothrrwebandinitSessionRecordingare registered, andrecordercarries the first without the second. - The capture address is aliased. The same rewrite turns
"analytics":{"endpoint":"/i/v0/e/"}into/i/v0/t/, and the proxy puts/e/back on the way out (captureAliasToUpstream). Of the rule's three parts only the path was ours to change: posthog-js always appendsip=, and droppingcompression=would send every replay snapshot uncompressed, which is the heaviest traffic the site has. Session replay is configured beside it and posts to/s/, which no list objects to, so that is left alone. - Dead clicks are carried, not fetched. The file name is fixed inside posthog-js, so
src/lib/utils/posthog.tsimports the 13 KB bundle and refuses that one fetch (useBundledDeadClicks). Carrying it is not enough by itself: 1.359.1 asks for the file whether or not the extension is already registered, andcapture_dead_clicks: falsedoes not stop it either.
Measured against the three lists after the change, on /app/uv and /app/design: every PostHog request passes, and all of the extensions register (rrweb, initSessionRecording, initDeadClicksAutocapture, surveys, web vitals). What still does not: the very first batch of a visit goes to /e/ before the remote config arrives and renames the endpoint, so a blocker refuses it. That is a few events at the start of a session rather than all of them.
If a list ever names lazy-recorder.js, the next step is to carry the recorder the way dead clicks are carried, which no filter can name; it is 248 KB, which is why it was not done first.
When PostHog and Google's tag load (2026-09-29)
PostHog and Google's tag load after a page is ready, not while it is starting. Until 2026-09-29 posthog-js (171 KB) was imported by hooks.client.ts and ran before any page could hydrate, for every visitor including the ones who had declined cookies, and Google's tag was an async script in the head that ran in the middle of hydration. Measured on production at four times CPU slowdown, the two together were about 800 ms of the three seconds the workspace could not be clicked after "Open workspace".
- PostHog is fetched when the page has loaded and the main thread goes idle (
requestIdleCallback, at most four seconds after load), and only whencookie_consentistrue. Without consent it never downloads. Every call made before it arrives (a page view, a product event, an exception, an identify) waits in a queue with the moment it happened, and runs in order once PostHog is ready, so events keep their real time. Code that captures events importsposthogfrom@/utils/posthog, whosecaptureandregisterqueue the same way; importingposthog-jsdirectly puts it back on the startup path of that page. - Google's tag (
G-4W97BWMFR2) keeps itsgtag('js')andgtag('config')calls inline inapp.html, so the page view is queued indataLayerfrom the first moment; the tag script itself is added when the page has loaded and gone idle, and sends what was queued. Google's tag still loads whatever the cookie answer is, as it did before: whether it should wait for consent is an owner decision.
A visitor who leaves within the first second or two, before the page goes idle, is not counted by either. Session replay starts at the same idle moment, so a recording begins a second or two into the visit.
Session replay records one session in five since 2026-09-29 (the owner's call: session_recording_sample_rate 0.20 in the PostHog project, set through the API; it was every session). PostHog decides per session, so a recorded visit is recorded whole. Surveys are switched off in the project (surveys_opt_in false; there were none, and the client still fetched surveys.js on every visit). Web vitals stay on: they are how real visitors' load times are read after the performance work.
Reading it
The funnels worth building in PostHog, all on surface = workspace: sign-in (auth_signin_started → auth_signin_completed, by surface), first file (workspace_opened → workspace_file_received → workspace_file_opened → workspace_job_created → workspace_job_completed → workspace_asset_downloaded), money (workspace_plans_opened → pricing_plan_selected → payment_subscription_checkout_started → payment_checkout_created), and Syntax (code_prompt_sent → code_reply_finished, broken down by model). Break any of them down by mode, game and tier, which every event carries.
Common questions
Does Zoovdev track me before I accept cookies? No. Capture is off by default and turns on only after Allow. Decline turns it off again and drops the id.
What does the workspace send about my files? The file's extension, its size and which tool opened it. Never the file, its name or its contents.
Can I see or delete my analytics data? Ask in the support channel; the owner can find and delete a person's events in PostHog by their user id.
Languages
Since 2026-09-20 this part of the site is available in English, French, German, Spanish, Portuguese, Polish and Dutch. It follows the browser's language on a first visit, and the language menu (the icon beside the profile in /app, or in the top navigation on public pages) changes it. Nothing about how it works, what it costs or what a plan includes differs by language; only the words do. File names, format names (GLB, YDR, YTD), product and model names, code, and some error lines that come from the server stay in English. Details and known gaps: Languages and translation.
Server events are sent after the response (2026-09-28)
image_generation_completed, image_texture_generated, tattoo_generation_completed, the image and tattoo exception reports and the checkout events (payment_checkout_created, payment_subscription_checkout_created, payment_saved_method_charge) are captured after the response is sent (Vercel waitUntil) instead of before it. Their names and properties are unchanged; their timestamps can be a second later than the response. The client entry no longer carries the OrbitControls shim (it moved to the 3D scenes), which changes nothing in analytics.