1. Why you need audit & lifecycle in a ChatGPT App at all
While you’re hacking on a prototype, the user is yourself, the DB is a local SQLite, and “incidents” are cured with git reset --hard, everything feels cozy and simple.
But once your GiftGenius (or another ChatGPT application (App)) gets real users, especially with payments and PII, suddenly there appear:
- client security folks asking “who can see our orders and who changed them?”;
- lawyers asking “how long do you store data and how do you process a ‘delete me’ request?”;
- production reality asking “what happens if a developer drops a table in prod?”.
In this lecture we will cover four foundational blocks:
- Audit logs — a separate logging layer for security and audit.
- Data retention — lifetimes for different data types and how to implement them.
- Deletion on user request — the “right to be forgotten” in practice.
- Business continuity & backups — how to survive failures without losing face and data.
We’ll tie examples to our training App where possible (a fictional GiftGenius on Next.js + Apps SDK + MCP).
2. Audit logs: who did what, when, and with what outcome
How audit logs differ from application logs
Regular application logs are friendly messages for developers. They contain stack traces, debug information, odd variable values, and ad‑hoc console.log("this definitely should not be null here"). They live briefly and are read by engineers.
Audit logs are a different universe. Their main audience is security teams, auditors, and sometimes lawyers. They don’t need a line like “NullPointer on line 55”, they need a record like “user X changed organization Y’s payment settings at such‑and‑such time, outcome — success.” Audit records usually live much longer (years) and are treated as evidence during investigations.
Key differences:
| Characteristic | Application logs | Audit logs |
|---|---|---|
| Purpose | Debugging, diagnostics | Security, compliance, investigations |
| Audience | Developers, SRE | Security, legal, sometimes regulators |
| Data composition | Tech details, stack trace | Who/what/when/to which resource/with what outcome |
| Retention | Weeks–a month | Years (often ≥ 1 year) |
| Operations on logs | Can delete/overwrite | Prefer append‑only, no UPDATE/DELETE |
OWASP and similar guidelines explicitly emphasize: it’s better to keep audit logs in a separate store or table, not mixed with the app’s regular logs.
What to log in the context of a ChatGPT application
For a ChatGPT application, especially with commerce, a reasonable minimum audit set is:
- authentication events: login, logout, login attempts;
- operations on critical data: creating/updating/deleting profiles, orders, payment settings;
- administrative actions: role changes, tenant settings changes;
- invocations of sensitive MCP/Agent tools: create_order, charge_customer, cancel_subscription, etc.
A good intuition: anything you would want to ask during an incident along the lines of “who did this and through what?” should go to audit.
Audit event structure
A handy mental model: each record is “who / what action / on what / in what context / with what outcome”. Often this is captured as a structure of who, action, resource, context, outcome.
For our GiftGenius, let’s describe an interface in TypeScript:
// lib/audit.ts
export type AuditAction =
| "auth.login"
| "auth.logout"
| "order.create"
| "order.cancel"
| "account.delete"
| "giftidea.generate";
export interface AuditEvent {
eventId: string; // uuid
timestamp: string; // ISO
actor: {
userId: string | null; // can be null before login
tenantId?: string | null;
ip?: string | null;
client: "chatgpt-app" | "admin-panel" | string;
};
action: AuditAction;
resource?: {
type: string; // "order", "user", ...
id?: string;
};
context?: {
mcpTool?: string;
requestId?: string;
};
outcome: {
status: "success" | "failure";
reason?: string | null;
};
}
Note that the event does not include full emails, card numbers, and other PII that we learned in previous lectures to mask and avoid logging unnecessarily.
Where and how to store audit logs
Minimum storage requirements:
- a separate table or even a separate DB from regular logs, making it harder to accidentally overwrite anything;
- preferably append‑only: technically this could be a policy “we never do UPDATE/DELETE on this table”, plus a DB role with only INSERT and SELECT permissions;
- restricted access: not every engineer should be able to read the full audit.
If you use PostgreSQL via Prisma/Drizzle, the model might look like this (simplified example):
CREATE TABLE audit_events (
event_id uuid PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
actor_user_id text,
actor_tenant_id text,
actor_ip inet,
action text NOT NULL,
resource_type text,
resource_id text,
context_mcp_tool text,
context_request_id text,
outcome_status text NOT NULL,
outcome_reason text
);
Adapt the schema to your needs, but the key is structure. One‑line JSON garbage will come back to haunt you.
Implementing audit in our App
Let’s create a small helper in a Next.js application (Node environment, for example in an MCP server or an API route):
// lib/audit.ts
import { randomUUID } from "crypto";
import { db } from "./db"; // your DB client
export async function logAudit(event: Omit<AuditEvent, "eventId" | "timestamp">) {
const full: AuditEvent = {
...event,
eventId: randomUUID(),
timestamp: new Date().toISOString(),
};
// In real life — via a queue/background; here we just insert
await db.insertInto("audit_events").values({
event_id: full.eventId,
created_at: full.timestamp,
actor_user_id: full.actor.userId,
action: full.action,
outcome_status: full.outcome.status,
outcome_reason: full.outcome.reason ?? null,
// ...other fields
});
}
Now let’s add a call in the handler that creates an order (assume this is an MCP tool or a server endpoint):
// app/api/orders/route.ts
export async function POST(req: Request) {
const user = await requireUser(req); // from the auth module
const body = await req.json();
const order = await createOrderInDb(user, body);
await logAudit({
actor: { userId: user.id, client: "chatgpt-app" },
action: "order.create",
resource: { type: "order", id: order.id },
context: { mcpTool: "create_order_tool" },
outcome: { status: "success" },
});
return Response.json(order);
}
Do the same around sensitive operations — order cancellation, changing payment details, deleting an account.
We now have a separate structured layer of audit — great. The next natural question is: how long should all these events (and other user data) live and what should we do with them when their time is up?
3. Data retention: how long your data lives
Why you can’t store everything forever
The engineering instinct “we might need it later” is very dangerous for user data.
First, the longer and larger you keep data, the heavier the consequences in case of a breach: the bigger the barrel of gasoline, the worse the fire. Many data protection guides call data a “toxic asset”: you should store it for a purpose, but minimize volume and duration.
Second, regulations like GDPR/CCPA introduce the principle “no longer than necessary for the purpose of processing.” That is, you can’t keep personal data indefinitely “just in case.” For each data type you should have clear retention periods and procedures for deletion or anonymization.
Third, cloud storage costs money. Large log tables and chat histories grow fast, and a year later you may discover that half of your provider bill is “yesterday’s garbage.”
Different data — different retention
Company experience and public guidelines suggest roughly this picture:
| Data type | Typical retention |
|---|---|
| Debug logs, tech metrics | from 1 to 12 months |
| Audit logs | ≥ 12 months, sometimes 2–5 years |
| Orders, payments, invoices | 3–7 years (per accounting/tax requirements) |
| Sessions, temporary tokens | hours–days |
| Raw chats/requests | from several weeks to several months, or do not store at all |
| Anonymous aggregates (analytics) | longer, since PII is gone |
Important: this is not legal advice, but engineering guidance. For a real product you will agree on terms with legal, but technically you should already be ready to implement different TTLs.
How to implement retention in code
The most common pattern: the table has created_at or expires_at, and you have a periodic process that deletes or anonymizes old records.
Example: cleaning regular logs older than 90 days.
// scripts/cleanup-logs.ts
import { db } from "../lib/db";
async function cleanup() {
await db
.deleteFrom("app_logs")
.where("created_at", "<", new Date(Date.now() - 90 * 24 * 60 * 60 * 1000));
console.log("Old logs removed");
}
cleanup().catch(console.error);
You can run such a script via cron, via a scheduled GitHub Action, or with a cloud scheduler.
For PII, anonymization is often used instead of deletion. For example, orders older than N years lose the link to a specific user:
UPDATE orders
SET user_id = NULL
WHERE created_at < now() - interval '3 years';
Amounts, items, and other “accounting” data are preserved, but the link to a specific person disappears.
Don’t forget that backups should have their own lifetimes too. We’ll discuss backup frequency and retention in the backup block, but the idea is the same: even archives shouldn’t live forever, or the “right to be forgotten” becomes fiction.
4. Deletion on user request: the “right to be forgotten” in code
Where the requirement comes from
The European GDPR (and similar laws) introduces the so‑called “right to be forgotten”: a user can request deletion of their personal data, and the company must do it without undue delay.
For an app developer this means: sooner or later you’ll get a request “delete all data about me” (or you’ll add a “Delete my data” button), and you’ll need to not only delete a row in the users table, but walk the entire trail: orders, sessions, tokens, activity logs, CRM, payments, etc.
However, there are also laws that require you to keep certain data: financial transactions, for instance. So there are more legal nuances here than purely technical ones.
What exactly to clean up
Minimum set for our GiftGenius:
- the user profile (name, email, settings);
- sessions, refresh tokens, links to OAuth providers;
- orders, if they are not needed in a “personalized form” (or can be anonymized);
- logs and audit records that contain PII inside (for example, a raw email).
At the same time, data important for reporting remains but de‑identified — order totals, number of transactions, aggregates by country, etc.
Example of a deletion algorithm
Scenario outline:
- The user (authenticated) clicks “Delete my account”.
- A request with their userId goes to the server.
- The server:
- deletes/anonymizes dependent records (orders, sessions, integrations);
- cleans PII in the profile;
- writes an audit log record “data deletion request processed”.
For simplicity we’ll show a minimal variant with a couple of tables. In a real product, around this same core you’ll add additional entities (integrations, third‑party services, etc.).
Service code in Next.js (simplified example):
// app/api/delete-me/route.ts
import { db } from "@/lib/db";
import { logAudit } from "@/lib/audit";
export async function POST(req: Request) {
const user = await requireUser(req);
await db.transaction(async (tx) => {
await tx.deleteFrom("sessions").where("user_id", "=", user.id);
await tx.deleteFrom("orders").where("user_id", "=", user.id);
await tx.updateTable("users")
.set({
is_deleted: true,
name: null,
email: null,
})
.where("id", "=", user.id);
await logAudit({
actor: { userId: user.id, client: "chatgpt-app" },
action: "account.delete",
outcome: { status: "success" },
});
});
return new Response(null, { status: 204 });
}
In the real world you’ll add calls to external APIs here (for example, Stripe — to detach the customer), and you’ll make the transaction more robust. But the principle is already there: everything in one place, with an audit record.
Interaction with backups
The tricky “what about backups” part raises many interesting questions. Even if you deleted the user from the production DB, their data may remain in nightly snapshots. To avoid turning this into “we never actually delete anyone”, there are two approaches:
- Backups themselves have a limited lifetime (for example, 30–90 days) and disappear along with the data. After retention expires, neither the primary DB nor archives contain the user.
- If you do bring the system up from a backup, you have a registry of “deleted” IDs, and after restoration you re‑run deletion/anonymization scripts.
Large companies sometimes practice crypto‑shredding: a user’s PII is encrypted with a separate key, and upon a deletion request you destroy the key. Even if encrypted copies remain somewhere (in logs, backups), without the key it’s useless garbage. This is great, but a bit of rocket science for a startup.
An important UX point
Remember that deletion isn’t just SQL. A user expects:
- a clear way to send a request (button, form, email);
- reasonable execution time (in practice up to 30 days);
- a success notification or a reasoned refusal (for example, when some data must be kept by law).
On the technical side, you’re now ready: you can perform cleanup, log the action, and avoid keeping unnecessary data in backups.
5. Business continuity & backups
Now imagine everything above works perfectly… until a fatal DROP TABLE on orders, a cloud outage, or a regional failure. We need mechanisms to bring the service back within reasonable time and not lose critical data.
RTO and RPO — two letters defining your pain
Two basic Disaster Recovery parameters:
- RTO (Recovery Time Objective) — how long you can afford to be unavailable. For example, if RTO = 1 hour, then after a serious incident you must bring the system up within an hour.
- RPO (Recovery Point Objective) — how much data in time you’re willing to lose. If RPO = 10 minutes, then during recovery you can lose the last 10 minutes of history, but no more.
The more critical the product (banking, trading systems), the closer both parameters are to zero. For the training GiftGenius you can live with RTO ~ a few hours and RPO ~ 15–60 minutes, but even that needs implementation.
What can go wrong in your stack
In the context of a ChatGPT app on Vercel + a cloud DB + external APIs, the list of common woes looks like this:
- OpenAI API is unavailable: your App returns errors on tool calls.
- Vercel (or someone else) has an outage: the widget can’t reach your backend.
- The database is corrupted or something is accidentally deleted (for example, DROP TABLE).
- An account controlling the infrastructure is broken or compromised.
You address this with a combination of backups, replication, and sensible application behavior during failures.
Backup strategies
Modern cloud Postgres/other DBs usually offer at least three options:
- Full backups + incrementals.
Take a full DB snapshot daily and store incremental changes in between. Recovery = roll back to a specific snapshot plus replay the change log. - Point‑in‑Time Recovery (PITR).
The database writes a transaction log (WAL) and lets you restore to an arbitrary moment in time (e.g., “state at 14:03:00, before we dropped the table”). - Replication to another region.
Keep a passive or active replica of the DB in another region/cloud. If you lose the primary region, you can switch the app to the replica, losing only data that hasn’t replicated yet.
For our scale it’s usually enough to enable PITR with your DB provider and periodic off‑site backups.
Simple example: daily dump for a local/dev DB
Even if you rely on a managed DB for production, for staging/dev you might want a simple script:
# scripts/backup.sh
#!/usr/bin/env bash
set -e
DATE=$(date +%F)
pg_dump "$DATABASE_URL" > "backups/backup-$DATE.sql"
echo "Backup created: backups/backup-$DATE.sql"
You can run this via cron or GitHub Actions. The main thing is not to forget that backups also need deletion by retention.
App behavior when external services fail
Backups and PITR solve “what if everything is broken or data is corrupted.” But in business reality, partial failures happen more often — an external API is down, the network is cut, the payment provider is stuck.
When the OpenAI API or a payment provider is down, the worst strategy is to return a naked 500 and a meaningless stack trace. Ideally:
- the backend returns a structured error like { error: "upstream_unavailable" };
- the widget shows a human‑readable message: “The service is temporarily unavailable, please try again later.”;
- the system doesn’t keep hammering a down API with endless retries (we’ll look at patterns like Circuit Breaker, etc., in the resilience module).
Example of an MCP tool handler that accounts for an upstream error:
// mcp/tools/createGiftIdea.ts
export async function createGiftIdea(args: Input): Promise<Output> {
try {
return await callOpenAiModel(args);
} catch (err) {
await logAudit({
actor: { userId: args.userId ?? null, client: "chatgpt-app" },
action: "giftidea.generate",
outcome: { status: "failure", reason: "openai_unavailable" },
});
throw new Error("UPSTREAM_UNAVAILABLE");
}
}
Your layer between MCP and the widget then knows how to display this error politely in the UI.
Restore testing: a backup without a restore is just a file
A classic anti‑pattern: a backup is taken daily, everyone is happy… until it turns out they can’t be restored (format changed, key lost, ran out of space).
Minimum plan:
- periodically (e.g., monthly) bring up a staging environment from a backup;
- walk through basic scenarios: login, create an order, App works;
- ensure that recovery time and data loss fit your RTO/RPO.
Instead of a full “DevOps religion” course, within this lecture it’s enough to understand: backup processes are part of your App architecture, not “something someone in the cloud will take care of.”
6. Visualization: data and event lifecycle
So it’s not just words, let’s draw two simple diagrams.
User data lifecycle
flowchart TD
A["Data creation<br/>(registration, order)"] --> B["Storage and use<br/>(prod DB)"]
B --> C["Archive/aggregation<br/>(anonymous metrics)"]
B --> D[Deletion request]
D --> E[Deletion/anonymization<br/>in prod DB]
E --> F["Backups expiration<br/>(retention)"]
The main idea: the data lifecycle doesn’t end in the prod DB — it continues in backups as well.
Audit flow for a sensitive action
sequenceDiagram
participant User as User
participant ChatGPT as ChatGPT
participant App as your backend/MCP
participant DB as Database
participant Audit as Audit storage
User->>ChatGPT: "Cancel order #123"
ChatGPT->>App: callTool cancel_order
App->>DB: UPDATE orders SET status='canceled'
App->>Audit: INSERT audit_event {actor, action, resource, outcome}
App-->>ChatGPT: Operation result
ChatGPT-->>User: Message about the result
7. Common mistakes in audit & lifecycle
Error No. 1: Mixing audit logs and application logs.
When all messages go into a single logs index, in six months nobody will tell apart “a user changed the admin role” from “we have another null reference.” Audit should contain structured business‑level events (see the section on audit event structure) and have a separate store with limited access.
Error No. 2: Logging PII in audit and debug logs.
Full email, phone, shipping address, last four digits of a card — these often end up in logs by accident. This increases breach risk and contradicts privacy recommendations. Instead, log identifiers and masked values.
Error No. 3: No retention policy — “store everything forever”.
At the MVP stage it seems “whatever”, and a year later your tables have monstrous sizes and every analytics query turns into a DDoS of your DB. Plus you violate the minimization principle embedded in modern data laws. Minimal TTLs by data type should be designed, and cleanup should be automated.
Error No. 4: “Deletion on request” == DELETE FROM users.
If you simply deleted the user’s row but left their PII in orders, sessions, and logs, you didn’t actually delete anyone. The correct approach is to transactionally walk through all related entities, and where deletion isn’t possible — anonymize. And don’t forget to log the deletion itself as an audit event.
Error No. 5: Ignoring backups when deleting data.
You deleted the user in prod — good, but their data lives in old snapshots for a year. When restoring from them, everything “resurrects,” and you violate your own promises to the user and your Privacy Policy. Either limit backup lifetimes or have a procedure to re‑apply deletions after restoration.
Error No. 6: “We have backups enabled, so we’re fine,” but nobody ever tried a restore.
A backup never tested for restore is just an expensive file. Without periodic restore testing, you don’t know your actual RTO/RPO nor whether your DR plan works at all. At minimum — regularly bring up staging from a backup using a checklist.
Error No. 7: Mismatch between documentation and reality.
Your Privacy Policy says you keep logs for 30 days and delete data on request, but in code everything stays forever. The ChatGPT Store, enterprise clients, and auditors will easily discover this with questions like “show your retention table” and “demonstrate deletion of a specific user.” Better build it first, then write it down.
GO TO FULL VERSION