Skip to content
Open
7 changes: 7 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,13 @@ if key = System.get_env("POSTHOG_API_KEY") do
posthog_host: System.get_env("POSTHOG_HOST", "https://us.i.posthog.com")
end

# Discord webhook for new issue reports (Engram.Notifications.Discord).
# No-op when DISCORD_WEBHOOK_URL is unset, so dev/test/self-host post
# nothing and the notifier short-circuits before any network call.
if url = System.get_env("DISCORD_WEBHOOK_URL") do
config :engram, :discord_webhook_url, url
end

# Pyroscope continuous CPU profiling. Same opt-in shape as Sentry/PostHog:
# the worker's child_spec/1 returns :ignore when any of the three required
# env vars is missing, so dev/test/self-host emit no profiling traffic and
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,13 @@
});
}

export function useReportBug() {
return useMutation({

Check failure on line 716 in frontend/src/api/queries.ts

View workflow job for this annotation

GitHub Actions / frontend lint+types+unit

src/settings/account-page.test.tsx > AccountPage > renders the section stack with no embedded Clerk UserProfile

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useMutation node_modules/@tanstack/react-query/src/useMutation.ts:28:17 ❯ useReportBug src/api/queries.ts:716:9 ❯ ReportBugDialog src/settings/account/report-bug-dialog.tsx:25:17 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22
mutationFn: (body: { description: string; surface: "web"; app_version: string }) =>
api.post<{ report: { id: string; status: string } }>("/reports", body),
});
}

export function useDeleteSelf() {
return useMutation<void, Error, { password: string }>({
mutationFn: async ({ password }) => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/settings/account-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DangerZoneSection } from "./account/danger-zone-section";
import { EmailSection } from "./account/email-section";
import { PasswordSection } from "./account/password-section";
import { ProfileSection } from "./account/profile-section";
import { ReportBugSection } from "./account/report-bug-section";
import { SessionsSection } from "./account/sessions-section";

// OAuth providers enabled on this Clerk instance. Confirm against the instance
Expand All @@ -27,6 +28,7 @@ export default function AccountPage() {
<ConnectedAccountsSection providers={[...OAUTH_PROVIDERS]} />
<CommunitySection />
<SessionsSection />
<ReportBugSection />
<DangerZoneSection />
</article>
);
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/settings/account/report-bug-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ReportBugDialog } from "./report-bug-dialog";

const mutate = vi.fn();
vi.mock("@/api/queries", () => ({
useReportBug: () => ({ mutate, isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

describe("ReportBugDialog", () => {
beforeEach(() => mutate.mockReset());

it("submits the typed description with surface web", () => {
render(<ReportBugDialog open onOpenChange={() => {}} />);
fireEvent.change(screen.getByPlaceholderText(/what happened/i), {
target: { value: "sync stalls" },
});
fireEvent.click(screen.getByRole("button", { name: /send report/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ description: "sync stalls", surface: "web" }),
expect.anything(),
);
});

it("disables submit when the description is empty", () => {
render(<ReportBugDialog open onOpenChange={() => {}} />);
expect(screen.getByRole("button", { name: /send report/i })).toBeDisabled();
});
});
80 changes: 80 additions & 0 deletions frontend/src/settings/account/report-bug-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState } from "react";
import { toast } from "sonner";
import { useReportBug } from "@/api/queries";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";

const inputClass =
"mt-1 block w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring";

export function ReportBugDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [description, setDescription] = useState("");
const report = useReportBug();

function submit() {
const text = description.trim();
if (!text) {
return;
}
report.mutate(
{ description: text, surface: "web", app_version: import.meta.env.VITE_GIT_SHA ?? "dev" },
{
onSuccess: () => {
toast.success("Report sent");
setDescription("");
onOpenChange(false);
},
onError: () => toast.error("Could not send report"),
},
);
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Report a bug</DialogTitle>
<DialogDescription>
Describe what went wrong. We attach your account and a time window so we can pull the
logs.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<textarea
className={inputClass}
rows={6}
placeholder="What happened?"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!description.trim() || report.isPending}>
Send report
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
21 changes: 21 additions & 0 deletions frontend/src/settings/account/report-bug-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ReportBugDialog } from "./report-bug-dialog";
import { SettingsSectionCard } from "./section-card";

export function ReportBugSection() {
const [open, setOpen] = useState(false);
return (
<SettingsSectionCard
title="Report a bug"
description="Something not working? Send us a report and we'll dig into the logs."
headerAction={
<Button size="sm" onClick={() => setOpen(true)}>
Report a bug
</Button>
}
>
<ReportBugDialog open={open} onOpenChange={setOpen} />
</SettingsSectionCard>
);
}
52 changes: 52 additions & 0 deletions lib/engram/notifications/discord.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule Engram.Notifications.Discord do
@moduledoc "Fire-and-forget Discord webhook for new issue reports."
require Logger

@window_seconds 600
@desc_limit 1500

@doc "Post a report to Discord if a webhook URL is configured; otherwise no-op."
def notify_report(report, user_email) do
case Application.get_env(:engram, :discord_webhook_url) do
url when is_binary(url) and url != "" ->
payload = build_report_payload(report, user_email)
# Fire-and-forget: discard the {:ok, pid} (matches :unmatched_returns).
_ = Task.start(fn -> post(url, payload) end)
:ok

_ ->
:ok
end
end

@doc "Build the Discord message payload for a report. Pure; unit-tested."
def build_report_payload(report, user_email) do
from = DateTime.add(report.inserted_at, -@window_seconds, :second)
to = DateTime.add(report.inserted_at, @window_seconds, :second)
logql = ~s({service_name="engram-backend"} | user_id="#{report.user_id}")

content = """
:beetle: **New issue report** (#{report.surface}, v#{report.app_version})
user: #{user_email} (`#{report.user_id}`)
when: #{DateTime.to_iso8601(report.inserted_at)}
window: #{DateTime.to_iso8601(from)} to #{DateTime.to_iso8601(to)}
logql: `#{logql}`

> #{String.slice(report.description, 0, @desc_limit)}#{ellipsis(report.description)}
"""

%{content: content, allowed_mentions: %{parse: []}}
end

defp ellipsis(s) do
if String.length(s) > @desc_limit, do: "…", else: ""
end

defp post(url, payload) do
case Req.post(url, json: payload, receive_timeout: 10_000, retry: :transient, max_retries: 2) do
{:ok, %{status: s}} when s in 200..299 -> :ok
{:ok, %{status: s}} -> Logger.warning("discord webhook non-2xx: #{s}")
{:error, err} -> Logger.warning("discord webhook failed: #{inspect(err)}")
end
end
end
33 changes: 33 additions & 0 deletions lib/engram/support.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule Engram.Support do
@moduledoc "User-submitted issue reports."
alias Engram.Repo
alias Engram.Support.IssueReport

@doc """
Insert an issue report. `user` supplies the trusted `user_id`; `attrs` are the
client-supplied `"description"`, `"surface"`, `"app_version"`; `meta` carries
server-derived `:vault_id` and `:device_fingerprint`.
"""
def create_report(user, attrs, meta) do
params = %{
"user_id" => user.id,
"vault_id" => meta[:vault_id],
"device_fingerprint" => meta[:device_fingerprint],
"surface" => attrs["surface"],
"app_version" => attrs["app_version"],
"description" => attrs["description"]
}

%IssueReport{}
|> IssueReport.changeset(params)
|> Repo.insert()
|> case do
{:ok, report} ->
Engram.Notifications.Discord.notify_report(report, user.email)
{:ok, report}

other ->
other
end
end
end
33 changes: 33 additions & 0 deletions lib/engram/support/issue_report.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule Engram.Support.IssueReport do
@moduledoc "Schema for a user-submitted issue report."
use Ecto.Schema
import Ecto.Changeset

# autogenerate: true so Ecto sets id client-side and returns it in the struct
# (the migration's uuidv7() default is a raw-insert fallback). Matches
# oauth/client.ex convention.
@primary_key {:id, :binary_id, autogenerate: true}
@surfaces ~w(plugin web)

schema "issue_reports" do
field :user_id, :binary_id
field :vault_id, :string
field :surface, :string
field :app_version, :string
field :device_fingerprint, :string
field :description, :string
field :status, :string, default: "open"
timestamps(type: :utc_datetime_usec, updated_at: false)
end

@cast ~w(user_id vault_id surface app_version device_fingerprint description status)a
@required ~w(user_id surface description)a

def changeset(report, attrs) do
report
|> cast(attrs, @cast)
|> validate_required(@required)
|> validate_inclusion(:surface, @surfaces)
|> validate_length(:description, min: 1, max: 5000)
end
end
48 changes: 48 additions & 0 deletions lib/engram_web/controllers/report_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule EngramWeb.ReportController do
@moduledoc "POST /api/reports: authenticated, rate-limited user issue reports."
use EngramWeb, :controller
alias Engram.Support

# 5 reports per hour per user.
@rate_scale_ms 3_600_000
@rate_limit 5

def create(conn, params) do
user = conn.assigns.current_user

if rate_limited?(user.id) do
conn |> put_status(429) |> json(%{error: "rate_limited"})
else
meta = %{
vault_id: conn |> get_req_header("x-vault-id") |> List.first(),
device_fingerprint: fingerprint(conn)
}

case Support.create_report(user, params, meta) do
{:ok, report} ->
conn |> put_status(201) |> json(%{report: %{id: report.id, status: report.status}})

{:error, changeset} ->
conn |> put_status(422) |> json(%{errors: format_errors(changeset)})
end
end
end

defp rate_limited?(user_id) do
case EngramWeb.RateLimiter.hit("report:#{user_id}", @rate_scale_ms, @rate_limit, :other) do
{:allow, _} -> false
{:deny, _} -> true
end
end

defp fingerprint(conn) do
ua = conn |> get_req_header("user-agent") |> List.first() || ""
EngramWeb.Plugs.DeviceFingerprint.hash_ua(ua)
end

defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end)
end)
end
end
5 changes: 5 additions & 0 deletions lib/engram_web/plugs/device_fingerprint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ defmodule EngramWeb.Plugs.DeviceFingerprint do
v -> v
end

hash_ua(ua)
end

@doc "12-char lowercase sha256 hex of a User-Agent string. Shared by the report controller."
def hash_ua(ua) when is_binary(ua) do
:crypto.hash(:sha256, ua)
|> Base.encode16(case: :lower)
|> binary_part(0, 12)
Expand Down
2 changes: 1 addition & 1 deletion lib/engram_web/plugs/host_rewrite.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ defmodule EngramWeb.Plugs.HostRewrite do
@api_top_segments ~w(
notes folders search vaults vault attachments oauth mcp auth admin billing
tasks health user me onboarding bootstrap api-keys connections tags sync
logs telemetry embed-status openapi
logs telemetry embed-status openapi reports
)

# Test-only accessor exposing the @api_top_segments allowlist so the
Expand Down
5 changes: 5 additions & 0 deletions lib/engram_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ defmodule EngramWeb.Router do
# SPA emits render/sync spans from the very first screen), so it lives in
# this scope rather than the vault-scoped/RequireOnboarding pipeline.
post "/telemetry/spans", TelemetryController, :create

# User-submitted issue reports. Must work for onboarding/billing-blocked
# users too (they are exactly who most needs to report a problem), so
# this lives here rather than the vault-scoped/RequireOnboarding pipeline.
post "/reports", ReportController, :create
end

# Self-host admin scope. 404 under Clerk (RequireAdmin gates on local auth);
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Engram.MixProject do
def project do
[
app: :engram,
version: "0.5.678",
version: "0.5.680",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Expand Down
Loading
Loading