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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ next-env.d.ts
skills/

superpowers/

*_plan.md
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Handle loading, empty, error, unauthorized, and retry states where relevant.

UI work must remain keyboard accessible, responsive, and readable.

Logs must be useful without exposing tokens, credentials, or sensitive payloads.
Logs must be useful without exposing tokens, credentials, or sensitive payloads. The same applies to telemetry: follow the "Never recorded" list in docs/OBSERVABILITY.md, and fix redaction rather than loosening the telemetry guard tests.

Database changes require migrations and a documented rollback consideration.

Expand Down
6 changes: 6 additions & 0 deletions app/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ GitHub__PrivateKey=""
GitHub__AppSlug=""
# Tests only: a stand-in for https://api.github.com. Leave unset.
# GitHub__ApiBaseUrl=""

# Optional telemetry (ADR-055, docs/OBSERVABILITY.md). Off when the endpoint is empty.
# Locally, the viewer from `npm run observability:up` is http://127.0.0.1:4318 and needs no header.
# OTEL_EXPORTER_OTLP_ENDPOINT=""
# Deployed only: Authorization=Basic%20<base64 of instanceId:token>. Secret.
# OTEL_EXPORTER_OTLP_HEADERS=""
75 changes: 75 additions & 0 deletions app/api/Observability/ObservabilitySetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Npgsql;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace SpecThread.Api.Observability;

// Sends traces, metrics, and logs over OTLP/HTTP when OTEL_EXPORTER_OTLP_ENDPOINT is set
// (ADR-055, docs/OBSERVABILITY.md). Without it nothing is registered, so local runs, tests,
// and CI behave exactly as before. OTEL_EXPORTER_OTLP_HEADERS carries the backend's credentials.
internal static class ObservabilitySetup
{
private const string EndpointSetting = "OTEL_EXPORTER_OTLP_ENDPOINT";

public static void AddSpecThreadObservability(this WebApplicationBuilder builder)
{
var endpoint = ReadEndpoint(builder.Configuration[EndpointSetting]);
if (endpoint is null) return;

// Framework Information logs repeat what spans already record, and some include raw
// request paths. Only warnings and errors from them are sent.
foreach (var category in new[] { "Microsoft", "System", "Npgsql" })
{
builder.Logging.AddFilter<OpenTelemetryLoggerProvider>(category, LogLevel.Warning);
}

builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
// Render sets RENDER_GIT_COMMIT to the deployed commit.
.AddService("specthread-api", serviceVersion: builder.Configuration["RENDER_GIT_COMMIT"])
.AddAttributes([new("deployment.environment.name", builder.Environment.EnvironmentName.ToLowerInvariant())]))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
// Render's health checks would outnumber real requests; metrics still count them.
options.Filter = context => context.Request.Path != "/health";
options.EnrichWithHttpResponse = RedactAndIdentify;
})
.AddHttpClientInstrumentation()
.AddNpgsql())
.WithMetrics(metrics => metrics
.AddMeter("Microsoft.AspNetCore.Hosting", "Microsoft.AspNetCore.Server.Kestrel", "System.Net.Http", "System.Runtime", "Npgsql"))
.WithLogging(configureBuilder: null, configureOptions: options => options.IncludeFormattedMessage = true)
.UseOtlpExporter(OtlpExportProtocol.HttpProtobuf, endpoint);
}

// The raw path can hold an invitation token, so it is replaced by the route template, or
// dropped when no route matched. The caller is recorded by user ID only, never email or name.
private static void RedactAndIdentify(Activity activity, HttpResponse response)
{
var context = response.HttpContext;
activity.SetTag("url.path", (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText);
var userId = context.User.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (userId is not null) activity.SetTag("user.id", userId);
}

// Blank means off. Anything else must be an HTTP(S) URL, so a typo fails the deploy visibly
// instead of silently sending nothing.
private static Uri? ReadEndpoint(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp))
{
return uri;
}
throw new InvalidOperationException(
$"{EndpointSetting} must be an HTTP(S) URL, or empty to turn telemetry off. See docs/OBSERVABILITY.md.");
}
}
2 changes: 2 additions & 0 deletions app/api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using SpecThread.Api.Data;
using SpecThread.Api.Documents;
using SpecThread.Api.GitHub;
using SpecThread.Api.Observability;
using SpecThread.Api.Projects;
using SpecThread.Api.Requirements;
using SpecThread.Api.Teams;
Expand All @@ -13,6 +14,7 @@

// Hosting request-start/finish logs include raw invitation secrets in the URL.
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning);
builder.AddSpecThreadObservability();

builder.Services.AddOpenApi();
builder.AddSpecThreadAuthentication();
Expand Down
5 changes: 5 additions & 0 deletions app/api/SpecThread.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
</ItemGroup>
</Project>
67 changes: 67 additions & 0 deletions app/api/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,52 @@
"Npgsql": "10.0.3"
}
},
"Npgsql.OpenTelemetry": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "ShsdG/FHxJ2VuWM6tebJL/mCWkcxymmvtENN59c85EsJdT4TStWwwtgZoBu/9UvC0qi1jpFZ6bOfl9ijiWD+vA==",
"dependencies": {
"Npgsql": "10.0.3",
"OpenTelemetry.API": "1.15.3"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Direct",
"requested": "[1.20.1, )",
"resolved": "1.20.1",
"contentHash": "IlO8F0Y01ki0N5hJaeFJ5j3MVYJdbyNpDF9g7goU8wA3o8r/+ir9WJNnmte7QlUzM3iSV/aIuev1fqwF1CZOqg==",
"dependencies": {
"OpenTelemetry": "1.20.1"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Direct",
"requested": "[1.20.1, )",
"resolved": "1.20.1",
"contentHash": "wnoK07COxyQBR47QwP80SIO4cpD2xeF4PrBDYFXDMnhDLmOP4nN0Szy1wCBLo5UABU1I4xwWRGzZM0bFdCbRog==",
"dependencies": {
"OpenTelemetry": "1.20.1"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Direct",
"requested": "[1.20.1, )",
"resolved": "1.20.1",
"contentHash": "pDO+Ed5iblMTvSVGcXzoLp8q20XHhXmK0wD/H3PGNHwDFlwZpV+bDWd/Euo6hNB4E0ruqzTFD76Btm+m0uCYNw==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.20.1, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Direct",
"requested": "[1.20.1, )",
"resolved": "1.20.1",
"contentHash": "XJTM6imVztxLraOrzsdlZf5shdH9TVeEWNBxpyepB5e3Gy+KNeQf2hyic398210UxqlPtt4iOkBELtjfIFTzug==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.20.1, 2.0.0)"
}
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
Expand Down Expand Up @@ -230,6 +276,27 @@
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.20.1",
"contentHash": "+XDw3fYbiv1a40aFnDJSQleeuUJciXuhoIA19XmcGhT47Hy/xZRqk8NCjHUDKrg72Ixjthicecp8ygBDNeH/Jw==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.20.1"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.20.1",
"contentHash": "QO9hlwadOdJGR4vDpJyDnl9B62v7epIBSTIHUA4C+bCtrRaB4TWmYSnu3QVOIe7caH8dt7NaHRei6zebESJJyw=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.20.1",
"contentHash": "eDzDY/jTBqRytkYECmVKF7ZP5sKmCIqv1SSnA5BSWltIIvc3931TOMRyCW1qCGNIgmtldtJhISnsmFNxOCfauQ==",
"dependencies": {
"OpenTelemetry.Api": "1.20.1"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
Expand Down
6 changes: 6 additions & 0 deletions app/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ SPECTHREAD_API_URL="http://127.0.0.1:5100"
# Optional dashboard integration; disabled unless an explicit key is configured.
BETTER_AUTH_API_KEY=""

# Optional telemetry (ADR-055, docs/OBSERVABILITY.md). Off when the endpoint is empty.
# Locally, the viewer from `npm run observability:up` is http://127.0.0.1:4318 and needs no header.
# OTEL_EXPORTER_OTLP_ENDPOINT=""
# Deployed only: Authorization=Basic%20<base64 of instanceId:token>. Secret.
# OTEL_EXPORTER_OTLP_HEADERS=""

# GitHub OAuth credentials (GitHub sign-in is disabled when both are empty;
# setting only one fails at startup)
# Callback URL: {BETTER_AUTH_URL}/api/auth/callback/github
Expand Down
3 changes: 3 additions & 0 deletions app/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
},
"dependencies": {
"@better-auth/infra": "0.4.9",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/sdk-trace-base": "2.12.0",
"@vercel/otel": "2.2.0",
"better-auth": "1.7.5",
"lucide-react": "^0.563.0",
"next": "16.3.5",
Expand Down
3 changes: 3 additions & 0 deletions app/web/src/app/app-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ a.row:focus-visible { outline-offset: -2px; }
.scaffold-status-mark { flex: none; width: 11px; height: 11px; margin-top: 5px; border: 2px solid #f5f5f5; border-radius: 50%; }
.scaffold-status strong { color: #eaeaea; font-size: 14px; font-weight: 600; }
.scaffold-status p { margin: 6px 0 0; color: #ababab; line-height: 1.5; }
.policy-section { max-width: 720px; margin-top: 40px; }
.policy-section h2 { margin: 0; color: #efefef; font-size: 18px; font-weight: 600; }
.policy-section p { margin: 12px 0 0; color: #b6b6b6; line-height: 1.6; }
.scaffold-navigation { display: block; margin-top: 44px; }
.scaffold-navigation-heading h2 { margin: 0; color: #efefef; font-size: 16px; font-weight: 600; }
.scaffold-navigation-heading p { margin: 6px 0 0; color: #979797; }
Expand Down
23 changes: 21 additions & 2 deletions app/web/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,26 @@ import { PolicyPage } from "@/components/policy-page";

export const metadata: Metadata = { title: "Privacy" };

/** Renders the public privacy page. */
/** Renders the public privacy page. The telemetry section reflects ADR-055. */
export default function PrivacyPage() {
return <PolicyPage title="Privacy" description="Privacy information for SpecThread users." />;
return (
<PolicyPage title="Privacy" description="Privacy information for SpecThread users.">
<section className="policy-section" aria-labelledby="privacy-telemetry">
<h2 id="privacy-telemetry">Operational telemetry</h2>
<p>
To keep SpecThread working, the web app and API record technical information about each
request: which page or API route was used, how long it took, whether it failed, and the
database queries it ran, without the values in them. Each record includes your account ID,
a random identifier rather than your name or email address, and for requests to the web
app, your IP address. Passwords, tokens, email addresses, and what you type or upload are
not recorded.
</p>
<p>
This information is sent to Grafana Labs, which stores it for us for about 14 days and then
deletes it. The SpecThread team uses it to find errors and slow pages, and to investigate
misuse such as repeated failed sign-ins or automated scraping.
</p>
</section>
</PolicyPage>
);
}
10 changes: 8 additions & 2 deletions app/web/src/components/policy-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/** Renders a public policy page, shown with the minimal public header and never the app sidebar. */
export function PolicyPage({ title, description }: { title: string; description: string }) {
import type { ReactNode } from "react";

/**
* Renders a public policy page, shown with the minimal public header and never the app sidebar.
* `children` holds the sections already published; the draft notice covers the rest.
*/
export function PolicyPage({ title, description, children }: { title: string; description: string; children?: ReactNode }) {
return (
<section className="scaffold-page" aria-labelledby="page-title">
<header className="scaffold-heading">
Expand All @@ -14,6 +19,7 @@ export function PolicyPage({ title, description }: { title: string; description:
<p>This page is planned. The full text is not published yet.</p>
</div>
</div>
{children}
</section>
);
}
11 changes: 11 additions & 0 deletions app/web/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Next.js calls this once when the server starts. Telemetry stays off unless
* OTEL_EXPORTER_OTLP_ENDPOINT is set (ADR-055, docs/OBSERVABILITY.md): without that check
* @vercel/otel would still try to send to localhost:4318.
*/
export async function register() {
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
if (process.env.NEXT_RUNTIME !== "nodejs" || !endpoint) return;
const { startTelemetry } = await import("./lib/telemetry");
startTelemetry(endpoint, process.env.OTEL_EXPORTER_OTLP_HEADERS);
}
Loading
Loading