Instrumenting Appwrite Backends: Cloud-Function Errors, Database Latency, and Login Drop-Off
Appwrite's dashboard shows requests. It doesn't show why your login funnel is leaking 40% of users.
Last month I watched a team debug an Appwrite signup issue for three hours. Their cloud function was throwing an error on every fifth request — some weird edge case with email validation. Appwrite's function logs showed the error. But they couldn't figure out why conversions dropped. The function error happened after the client received a success response (Appwrite's async execution model), so users saw a blank dashboard and bounced.
The function logs existed. The analytics existed. The error tracking existed. None of them talked to each other.
This is the Appwrite monitoring gap. Appwrite gives you a beautiful dashboard for function executions and database stats. Pretty graphs. Nice numbers. But it doesn't tell you that the users who hit function timeouts are the same users who never completed onboarding. That's what this tutorial fixes.
What you'll have at the end
A working observability setup for your Appwrite app that captures:
- Auth funnel events: signup_started, signup_completed, signup_failed with specific error codes (wrong password vs rate limited vs account exists)
- Cloud function monitoring: execution time, success/failure, error details — correlated to the user session that triggered them
- Database operation latency: slow query detection for collections, so you know when that user list query takes 3 seconds instead of 200ms
- Client-side errors: SDK exceptions, network failures, any uncaught errors
All in one dashboard. When signups drop, you'll see the auth failure spike and the slow database query and the function timeout in the same view.
Is this overkill? Maybe. Probably not. I've seen too many teams lose a weekend to something that would've been obvious with unified observability. Twenty minutes now saves you the 2am debugging session later. (Ask me how I know.)
Prerequisites
- An Appwrite project (Cloud or self-hosted — both work)
- A frontend framework (we're using Next.js but React, Vue, or plain JS is fine)
- Node.js 18+ for any server-side pieces
- A JustAnalytics account — the free tier gives you 100K events/month, which is plenty for testing
- Basic familiarity with Appwrite's SDK (account, databases, functions)
Step 1: Add the JustAnalytics script and create tracking utilities
Start with the under-5KB script in your app's entry point. For Next.js with App Router:
// app/_components/Analytics.tsx
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import Script from "next/script";
export function Analytics({ siteId }: { siteId: string }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (typeof window === "undefined") return;
const url = pathname + (searchParams?.toString() ? `?${searchParams}` : "");
window.ja?.("pageview", { url });
}, [pathname, searchParams]);
return (
<Script
src="https://cdn.justanalytics.app/script.js"
data-site={siteId}
data-auto-pageview="false"
strategy="afterInteractive"
/>
);
}
Mount it in your root layout:
// app/layout.tsx
import { Suspense } from "react";
import { Analytics } from "./_components/Analytics";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Suspense fallback={null}>
<Analytics siteId={process.env.NEXT_PUBLIC_JA_SITE_ID!} />
</Suspense>
</body>
</html>
);
}
Now add tracking utilities:
// lib/analytics.ts
export function trackEvent(
eventName: string,
properties?: Record<string, unknown>
) {
if (typeof window !== "undefined" && window.ja) {
window.ja("event", eventName, properties);
}
}
export function trackError(
error: Error,
context?: Record<string, unknown>
) {
if (typeof window !== "undefined" && window.ja) {
window.ja("event", "client_error", {
error_name: error.name,
error_message: error.message.slice(0, 500),
stack: error.stack?.slice(0, 2000),
...context,
});
}
}
Pageviews are now tracking. The real work starts here.
Step 2: Wrap Appwrite auth calls for funnel tracking
Appwrite's account SDK gives you createEmailPasswordSession for login and create for signup. Neither tells you about failures unless you wrap them.
Here's a complete auth wrapper:
// lib/appwrite-auth.ts
import { Client, Account, ID } from "appwrite";
import { trackEvent, trackError } from "./analytics";
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!);
const account = new Account(client);
// Track auth state changes (catches successful logins/logouts)
// Note: Appwrite doesn't have a built-in listener like Supabase/Firebase,
// so we'll track manually in the wrapped functions below.
export async function signUp(email: string, password: string, name?: string) {
trackEvent("signup_started", { method: "email" });
try {
const user = await account.create(ID.unique(), email, password, name);
// Auto-login after signup
await account.createEmailPasswordSession(email, password);
trackEvent("signup_completed", {
method: "email",
user_id: user.$id,
});
return user;
} catch (error: any) {
trackEvent("signup_failed", {
method: "email",
error_code: error.code,
error_type: error.type,
error_message: error.message,
});
throw error;
}
}
export async function login(email: string, password: string) {
trackEvent("login_started", { method: "email" });
try {
const session = await account.createEmailPasswordSession(email, password);
trackEvent("login_completed", {
method: "email",
user_id: session.userId,
});
return session;
} catch (error: any) {
trackEvent("login_failed", {
method: "email",
error_code: error.code,
error_type: error.type,
error_message: error.message,
});
throw error;
}
}
export async function logout() {
try {
await account.deleteSession("current");
trackEvent("logout_completed", {});
} catch (error: any) {
trackError(error, { context: "logout" });
throw error;
}
}
export { account, client };
The key here: Appwrite error codes are actually helpful. (Rare for backend services, honestly.) user_already_exists vs password_recently_used vs rate_limit_exceeded tells you exactly what went wrong. Track the code, not just a generic "failed" event. If you're comparing Appwrite to alternatives like Supabase or Firebase, this granular error visibility is a major plus.
I spent way too long early on just tracking "login_failed" without the error code. Useless. You'll know something broke but never why.
Step 3: Track database operation latency
Slow database queries kill user experience silently. Appwrite's console shows aggregate stats, but you can't correlate a slow query to a specific user session. Let's fix that.
// lib/appwrite-db.ts
import { Client, Databases, Query, ID } from "appwrite";
import { trackEvent } from "./analytics";
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!);
const databases = new Databases(client);
const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
// Wrapper for timed database operations
async function timedDbOperation<T>(
operation: string,
collection: string,
fn: () => Promise<T>,
queryInfo?: string
): Promise<T> {
const startTime = performance.now();
try {
const result = await fn();
const durationMs = Math.round(performance.now() - startTime);
// Only track slow queries (>500ms) or all operations based on your preference
if (durationMs > 500) {
trackEvent("db_slow_query", {
operation,
collection,
duration_ms: durationMs,
query_info: queryInfo?.slice(0, 200),
});
}
return result;
} catch (error: any) {
const durationMs = Math.round(performance.now() - startTime);
trackEvent("db_error", {
operation,
collection,
duration_ms: durationMs,
error_code: error.code,
error_message: error.message?.slice(0, 200),
});
throw error;
}
}
// Wrapped database methods
export async function listDocuments(
collectionId: string,
queries: string[] = []
) {
return timedDbOperation(
"list",
collectionId,
() => databases.listDocuments(DATABASE_ID, collectionId, queries),
queries.join(", ")
);
}
export async function getDocument(collectionId: string, documentId: string) {
return timedDbOperation(
"get",
collectionId,
() => databases.getDocument(DATABASE_ID, collectionId, documentId)
);
}
export async function createDocument(
collectionId: string,
data: Record<string, unknown>,
permissions?: string[]
) {
return timedDbOperation(
"create",
collectionId,
() => databases.createDocument(DATABASE_ID, collectionId, ID.unique(), data, permissions)
);
}
export { databases, DATABASE_ID };
That 500ms threshold? Totally arbitrary. Adjust based on your app's tolerance. For a real-time dashboard, maybe 200ms is too slow. For a batch reporting page, maybe 2 seconds is fine. I picked 500ms because it felt right. Very scientific.
(One thing I learned the hard way: don't track every single database call in high-traffic apps. You'll blow through your event budget fast. Track slow queries and errors. That's what matters. For a deeper dive on database performance tracking patterns, see our OpenTelemetry distributed tracing guide.)
Step 4: Instrument Appwrite Cloud Functions
Appwrite Functions run on various runtimes — Node.js, Python, Deno, PHP, and more. For Node.js functions, use the REST API directly since the function runtime is isolated:
// functions/onboarding-process/src/main.js
import { Client, Users, Databases } from 'node-appwrite';
const JA_API_KEY = process.env.JUSTANALYTICS_API_KEY;
const JA_SITE_ID = process.env.JUSTANALYTICS_SITE_ID;
async function trackFunctionEvent(eventName, properties) {
if (!JA_API_KEY) return;
try {
await fetch("https://api.justanalytics.app/v1/events", {
method: "POST",
headers: {
Authorization: `Bearer ${JA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
site_id: JA_SITE_ID,
event: eventName,
properties,
}),
});
} catch {
// fail silently — never break the function for analytics
}
}
export default async ({ req, res, log, error }) => {
const startTime = Date.now();
const functionName = "onboarding-process";
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
try {
const payload = JSON.parse(req.body || "{}");
const userId = payload.userId;
// Your actual function logic here
const databases = new Databases(client);
await databases.createDocument(
process.env.DATABASE_ID,
"user_profiles",
userId,
{ onboarded: true, onboarded_at: new Date().toISOString() }
);
const durationMs = Date.now() - startTime;
// Fire and forget — don't await
trackFunctionEvent("function_success", {
function_name: functionName,
duration_ms: durationMs,
user_id: userId,
});
return res.json({ success: true });
} catch (err) {
const durationMs = Date.now() - startTime;
trackFunctionEvent("function_error", {
function_name: functionName,
duration_ms: durationMs,
error_name: err.name,
error_message: String(err.message).slice(0, 500),
});
error(err.message);
return res.json({ success: false, error: "Processing failed" }, 500);
}
};
Set JUSTANALYTICS_API_KEY and JUSTANALYTICS_SITE_ID in your function's environment variables via the Appwrite Console (Functions → Your Function → Settings → Variables).
The tracking call fires async and doesn't block the response. If the runtime terminates before the fetch completes, you might lose the event on very fast functions — but that's a tradeoff I'll take over adding latency to every response. Perfection is the enemy of shipping.
Step 5: Catch global client errors
Both Appwrite SDK errors and any uncaught exceptions should land in your dashboard:
// lib/global-error-handler.ts
import { trackError } from "./analytics";
if (typeof window !== "undefined") {
window.addEventListener("error", (event) => {
trackError(event.error || new Error(event.message), {
source: "window_error",
filename: event.filename,
lineno: event.lineno,
});
});
window.addEventListener("unhandledrejection", (event) => {
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
trackError(error, { source: "unhandled_rejection" });
});
}
Import this once in your app entry. Every uncaught Appwrite SDK error — network timeouts, permission failures, whatever — now lands in your dashboard alongside your auth funnel data.
Common errors and how to fix them
"ja is not defined" or "window.ja is undefined": The script hasn't loaded yet. Classic Next.js footgun. Make sure you're calling trackEvent after mount, not during SSR. The typeof window !== "undefined" guard should catch this.
Function events not appearing: Check that JUSTANALYTICS_API_KEY is set in Appwrite's function variables, not just your local .env. The Appwrite Console shows function variables under Settings.
Database events flood your dashboard: Lower the threshold for slow queries (the 500ms check) or remove the timing for high-frequency operations. Track errors always, track latency selectively.
Auth events duplicated: You might be tracking in multiple places — the wrapper function and somewhere else. I did this. Twice. Dedupe in your code or filter in the dashboard.
Events don't show up at all: Ad blockers can catch analytics scripts. Test in an incognito window without extensions. If you're testing across multiple browser profiles, JustBrowser keeps sessions cleanly separated. For teams doing QA across environments, this separation is essential.
Next steps
You've got unified visibility into your Appwrite backend — auth flows, database latency, function executions, and client errors all in one place. The next step is building conversion funnels to see exactly where users drop off. For pattern inspiration, check our tutorial on correlating errors with funnel drop-off.
If you're running background jobs on Appwrite Functions (scheduled tasks, cron-style workloads), heartbeat monitoring catches silent failures before your users notice — see our cron monitoring guide for the pattern.
For teams driving traffic via paid ads, the auth funnel data pairs well with ClickzProtect for spotting fraudulent signups that never convert — bots that create accounts and bounce. You can correlate signup events with ad clicks to identify click fraud patterns. And if you're running an AI-assisted development workflow with Claude or Cursor, our AI Command Center gives your IDE direct access to observability data through MCP.
The complete code from this tutorial is designed to be copy-paste-ready. Variable names could be better — I wrote most of this at 1am after helping that team debug their signup flow.
Frequently Asked Questions
Does JustAnalytics work with Appwrite's Deno and Node.js function runtimes?
Yes. Appwrite supports multiple runtimes, and the approach differs slightly. For Node.js functions, you can use our Node SDK directly with npm install. For Deno functions, use the REST API — same pattern as Supabase Edge Functions. Both are covered in this tutorial. The key is wrapping your function logic in try/catch and sending execution time plus any error details.
Can I track Appwrite auth events without modifying my client code?
Partially. Appwrite's client SDK emits auth state changes that you can subscribe to, which catches successful logins and logouts. But failed login attempts — wrong password, account disabled, rate limited — don't trigger those events. To capture why logins fail, you need to wrap your account.createEmailPasswordSession calls. This tutorial shows both approaches.
How do I monitor Appwrite database query performance?
Wrap your database operations with timing. Before the query, capture performance.now(). After, calculate the delta. Send that as a custom event with the collection name, operation type (list, get, create, update, delete), and duration in milliseconds. For slow queries, include the query filters so you know which queries to optimize.
Will this add latency to my Appwrite function responses?
The tracking calls are async and fire-and-forget. We don't await the analytics call before returning the response. In practice, this adds 0ms to your user-facing function because the event send happens after your return statement. The only risk is if your runtime terminates before the fetch completes — for short-lived functions, consider using a 2-second timeout on the fetch.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.