Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 8 additions & 17 deletions app/src/tabs/OnboardingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,6 @@ export function OnboardingTab() {
) as string | null | undefined;
const rotateWidgetSecret = useMutation(api.teams.rotateWidgetSecret);

const widgetSnippet = `<script
src="https://YOUR-STATIC-HOST/otto.js"
data-endpoint="${siteUrl || "https://YOUR-CONVEX.convex.site"}/ingest/widget"
data-secret="${widgetSecret ?? "<click 'create widget secret' below>"}"
defer
></script>`;

const slackInteractionsUrl = `${siteUrl || "https://YOUR-CONVEX.convex.site"}/slack/interactions`;

return (
Expand Down Expand Up @@ -129,13 +122,11 @@ export function OnboardingTab() {
required="required"
>
<p>
otto&rsquo;s primary input. build the bundle with{" "}
<code>npm run widget:build</code>, host{" "}
<code>widget/dist/otto.js</code> on any static host, then paste
this onto the qa or prod build of your app — your team uses it
to flag bugs and feedback.
otto&rsquo;s primary input. each project has its own snippet
(find it on the project page) — feedback from a project&rsquo;s
widget routes there directly. you only need to set the team
secret once, here.
</p>
<CodeBlock language="html" code={widgetSnippet} />
<div className="row" style={{ gap: 8 }}>
<button
type="button"
Expand All @@ -153,10 +144,10 @@ export function OnboardingTab() {
)}
</div>
<Hint>
the secret above is per-team and lives in this deployment&rsquo;s
database. paste the snippet on the page you want to collect
feedback from — otto figures out which project (and therefore
which repo) by matching the URL against your project patterns.
go to <strong>projects → pick one → install the widget</strong>{" "}
to grab the per-project snippet. the team secret above is the
shared piece; the project id baked into each snippet tells
otto where the feedback belongs.
</Hint>
</Step>

Expand Down
79 changes: 72 additions & 7 deletions app/src/tabs/ProjectsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,30 @@ function ProjectDetail({
items: Item[] | undefined;
onBack: () => void;
}) {
const { teamId } = useTeam();
const [editing, setEditing] = useState(false);
const projectItems =
items?.filter((it) => it.projectId === project._id) ?? [];
const projectStats = items ? computeStats(projectItems) : null;
const noEventsYet = items && projectItems.length === 0;

// Per-project widget snippet: includes the team secret + this
// project's id so feedback flows here directly without URL-pattern
// matching.
const widgetSecret = useQuery(
api.teams.widgetSecret,
teamId ? { teamId } : "skip",
) as string | null | undefined;
const convexUrl = (import.meta.env.VITE_CONVEX_URL as string | undefined) ?? "";
const siteUrl = convexUrl.replace(".convex.cloud", ".convex.site");
const snippet = `<script
src="https://YOUR-STATIC-HOST/otto.js"
data-endpoint="${siteUrl || "https://YOUR-CONVEX.convex.site"}/ingest/widget"
data-secret="${widgetSecret ?? "<create the team widget secret in settings>"}"
data-project="${project._id}"
defer
></script>`;

return (
<>
<div
Expand Down Expand Up @@ -329,14 +347,19 @@ function ProjectDetail({
<div className="card">
<h3 style={{ marginTop: 0 }}>install the widget</h3>
<p className="muted" style={{ fontSize: 12 }}>
otto isn't seeing events for this project yet. drop the snippet
below into your app and otto will route feedback here whenever
the page url matches one of your patterns.
</p>
<p style={{ fontSize: 12 }}>
grab the snippet (with your team secret baked in) from{" "}
<strong>settings → drop the widget</strong>.
otto isn't seeing events for this project yet. paste this
snippet on the staging or prod build of your app — feedback
from anyone using the widget on that page lands here.
</p>
{widgetSecret ? (
<SnippetBlock code={snippet} />
) : (
<p style={{ fontSize: 12 }}>
create the team widget secret in{" "}
<strong>settings → drop the widget</strong> first, then
come back to grab the snippet.
</p>
)}
</div>
)}

Expand Down Expand Up @@ -707,3 +730,45 @@ function shortSource(ref: string): string {
return ref;
}
}

function SnippetBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
return (
<div style={{ position: "relative" }}>
<pre
style={{
fontFamily: "var(--otto-font-mono)",
fontSize: 11,
background: "var(--otto-bg)",
border: "1px solid var(--otto-rule, rgba(28,26,22,0.18))",
padding: 12,
margin: "8px 0 0",
overflowX: "auto",
}}
>
{code}
</pre>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {
/* clipboard blocked */
}
}}
style={{
position: "absolute",
top: 4,
right: 4,
fontSize: 11,
padding: "2px 8px",
}}
>
{copied ? "copied" : "copy"}
</button>
</div>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New SnippetBlock duplicates existing CodeBlock component

Low Severity

SnippetBlock in ProjectsTab.tsx duplicates the existing CodeBlock in OnboardingTab.tsx — both render a pre with code content and a copy button using identical clipboard logic (navigator.clipboard.writeText + setTimeout to reset "copied" state after 1400ms). Extracting a shared component would avoid inconsistent bug fixes and styling drift.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80ed5c8. Configure here.

19 changes: 18 additions & 1 deletion convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,26 @@ http.route({
const sourceRef =
typeof body?.url === "string" ? body.url : "widget:unknown";

// Optional `projectId` from the widget's `data-project` attribute.
// We validate ownership here so the parser can trust it later. If
// the projectId is malformed or belongs to a different team we
// silently drop it and fall back to URL-pattern routing.
let projectId: any = undefined;
if (typeof body?.projectId === "string" && body.projectId) {
try {
const owned = await ctx.runQuery(
internal.ingest.verifyProjectInTeam,
{ teamId, projectId: body.projectId as any },
);
if (owned) projectId = body.projectId;
} catch {
// malformed id format — ignore
}
}

const trackingId: string = await ctx.runMutation(
internal.ingest.recordWidget,
{ teamId, sourceRef, payload: body },
{ teamId, sourceRef, payload: body, projectId },
);

return new Response(JSON.stringify({ trackingId }), {
Expand Down
19 changes: 18 additions & 1 deletion convex/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ export const recordWidget = internalMutation({
teamId: v.id("teams"),
sourceRef: v.string(),
payload: v.any(),
// Optional — set when the widget snippet declared `data-project`.
// The HTTP route already validated ownership against the team, so
// we trust it here.
projectId: v.optional(v.id("projects")),
},
handler: async (ctx, { teamId, sourceRef, payload }) => {
handler: async (ctx, { teamId, sourceRef, payload, projectId }) => {
const id = await ctx.db.insert("ingestEvents", {
sourceType: "widget",
sourceRef,
payload,
receivedAt: Date.now(),
teamId,
projectId,
});

await ctx.db.insert("auditLog", {
Expand All @@ -98,6 +103,18 @@ export const recordWidget = internalMutation({
},
});

// Used by the widget HTTP route to confirm a posted projectId belongs
// to the team that owns the secret. If the project was deleted or
// belongs to a different team, the route ignores the field and falls
// back to URL-pattern routing.
export const verifyProjectInTeam = internalQuery({
args: { teamId: v.id("teams"), projectId: v.id("projects") },
handler: async (ctx, { teamId, projectId }) => {
const p = await ctx.db.get(projectId);
return p && p.teamId === teamId ? true : false;
},
});

// Used by the public widget HTTP route to map a posted secret to a
// team. Per-team widget secrets live in `settings` under the
// "widget.secret" key. We deliberately scan rather than index by
Expand Down
22 changes: 19 additions & 3 deletions convex/parserDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,24 @@ export const persistItems = internalMutation({
{ ingestEventId, teamId, sourceType, sourceRef, widgetUrl, items },
) => {
let project: { projectId: any; primaryRepoId: any } | null = null;
if (sourceType === "widget" && widgetUrl) {
project = await resolveProjectFromUrl(ctx, teamId, widgetUrl);
let routedBy: "snippet" | "url-pattern" | "router" = "router";

if (sourceType === "widget") {
// Prefer the `data-project` declared on the widget snippet — it's
// already team-validated at the HTTP route. Fall back to URL
// pattern matching only when the snippet didn't declare one.
const ev = await ctx.db.get(ingestEventId);
if (ev?.projectId) {
const p = await ctx.db.get(ev.projectId);
if (p && p.teamId === teamId) {
project = { projectId: p._id, primaryRepoId: p.primaryRepoId };
routedBy = "snippet";
}
}
if (!project && widgetUrl) {
project = await resolveProjectFromUrl(ctx, teamId, widgetUrl);
if (project) routedBy = "url-pattern";
}
}

for (const it of items) {
Expand All @@ -62,7 +78,7 @@ export const persistItems = internalMutation({
payload: {
description: it.description,
confidence: it.confidence,
routedBy: project?.primaryRepoId ? "url-pattern" : "router",
routedBy: project ? routedBy : "router",
projectId: project?.projectId ?? null,
},
actor: "system",
Expand Down
4 changes: 4 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default defineSchema({
payload: v.any(),
receivedAt: v.number(),
teamId: v.optional(v.id("teams")),
// Set when the widget snippet declared `data-project="<id>"`.
// The HTTP ingest route validates team ownership before persisting,
// so the parser can trust this and skip URL-pattern matching.
projectId: v.optional(v.id("projects")),
})
.index("by_source", ["sourceType", "sourceRef"])
.index("by_team", ["teamId"]),
Expand Down
5 changes: 5 additions & 0 deletions widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const OTTER_ERROR = SVG_PREFIX + OTTER_ERROR_B64;
const currentScript = document.currentScript as HTMLScriptElement | null;
const ENDPOINT = currentScript?.dataset.endpoint;
const SECRET = currentScript?.dataset.secret;
// Optional. When set, every event from this page is tagged with the
// project id and routed there directly — bypassing URL-pattern
// matching. Per-project snippets are the recommended install path.
const PROJECT_ID = currentScript?.dataset.project ?? null;
if (!ENDPOINT || !SECRET) {
console.warn("[otto] missing data-endpoint or data-secret on script tag");
return;
Expand Down Expand Up @@ -416,6 +420,7 @@ const OTTER_ERROR = SVG_PREFIX + OTTER_ERROR_B64;
userAgent: navigator.userAgent,
viewport: { w: innerWidth, h: innerHeight },
at: Date.now(),
projectId: PROJECT_ID,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Expand Down
Loading