Skip to content
Closed
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
23 changes: 18 additions & 5 deletions apps/web/src/components/ingest/guided-setup.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,21 @@ import { ingestUrl } from "@/lib/services/common/ingest-url"
import { ConnectInstructions, useGuidedFramework } from "./guided-setup"
import { ONBOARD_SKILL_COMMAND, onboardSkillPrompt } from "./onboard-skill"

const API_KEY = "mpl_ingest_supersecretkey123"
const API_KEY = "maple_pk_publickey123"

describe("ConnectInstructions", () => {
afterEach(cleanup)

it("keeps the ingest key out of session replay", () => {
const { container } = render(
<ConnectInstructions framework="nodejs" apiKey={API_KEY} showCredentials />,
<ConnectInstructions framework="nodejs" apiKey={API_KEY} />,
)

fireEvent.click(screen.getByRole("tab", { name: "Instrument" }))
fireEvent.click(screen.getByRole("tab", { name: "Claude Code" }))

// rrweb blocks an `rr-block` element with its whole subtree, so every plain-text
// rendering of the key must sit under one. The credentials column renders into a
// readonly <input>, which `maskAllInputs: true` already masks.
// rendering of the key must sit under one.
const blocked = [...container.querySelectorAll(`.${REPLAY_BLOCK_CLASS}`)]
expect(blocked.length).toBeGreaterThan(0)
for (const el of container.querySelectorAll("pre")) {
Expand All @@ -40,7 +39,7 @@ describe("ConnectInstructions", () => {

it("gives the agent the org's ingest endpoint and installs the companion skills", () => {
const { container } = render(
<ConnectInstructions framework="nodejs" apiKey={API_KEY} showCredentials />,
<ConnectInstructions framework="nodejs" apiKey={API_KEY} />,
)

fireEvent.click(screen.getByRole("tab", { name: "Claude Code" }))
Expand All @@ -49,6 +48,20 @@ describe("ConnectInstructions", () => {
expect(blocks).toContain(ONBOARD_SKILL_COMMAND)
expect(blocks).toContain(onboardSkillPrompt(ingestUrl, API_KEY))
})

it("keeps the key out of the server snippet, which reads MAPLE_INGEST_KEY", () => {
const { container } = render(<ConnectInstructions framework="nodejs" apiKey={API_KEY} />)

fireEvent.click(screen.getByRole("tab", { name: "Instrument" }))

const snippet =
[...container.querySelectorAll("pre")]
.map((el) => el.textContent ?? "")
.find((t) => t.includes("NodeSDK")) ?? ""
expect(snippet).toContain("process.env.MAPLE_INGEST_KEY")
expect(snippet).toContain(`${ingestUrl}/v1/traces`)
expect(snippet).not.toContain(API_KEY)
})
})

function FrameworkProbe() {
Expand Down
25 changes: 9 additions & 16 deletions apps/web/src/components/ingest/guided-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import {
PythonIcon,
} from "@/components/quick-start/framework-icons"
import { sdkSnippets, type FrameworkId } from "@/components/quick-start/sdk-snippets"
import { ConnectCredentials } from "@/components/ingest/connect-credentials"
import { ONBOARD_SKILL_COMMAND, onboardSkillPrompt } from "@/components/ingest/onboard-skill"
import { ingestUrl } from "@/lib/services/common/ingest-url"
import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode"
import { useQuickStart } from "@/hooks/use-quick-start"
import type { OnboardingRole } from "@/lib/onboarding-role"
import { CopyableField } from "@maple/ui/components/ui/copyable-field"

const frameworkIconMap: Record<FrameworkId, React.ComponentType<{ size?: number; className?: string }>> = {
nextjs: NextjsIcon,
Expand All @@ -40,9 +40,9 @@ const ROLE_DEFAULT_FRAMEWORK = {
} satisfies Record<OnboardingRole, FrameworkId>

interface GuidedSetupProps {
/** Public ingest key, interpolated into the instrument snippet. */
/** Public ingest key, for the Claude Code prompt. Server snippets read MAPLE_INGEST_KEY. */
apiKey: string
/** Render the endpoint + key credentials column beside the snippet tabs. */
/** Render the endpoint + keys credentials column beside the snippet tabs. */
showCredentials?: boolean
}

Expand Down Expand Up @@ -171,12 +171,6 @@ export function ConnectInstructions({

const contentPadding = variant === "boxed" ? "p-3" : "px-4 pt-3 pb-4"

function interpolate(template: string) {
return template
.replace(/\{\{INGEST_URL\}\}/g, ingestUrl)
.replace(/\{\{API_KEY\}\}/g, apiKey || "<your-api-key>")
}

const tabs = (
<div className={variant === "boxed" ? "rounded-lg border bg-card overflow-hidden" : undefined}>
<Tabs defaultValue="install" className="flex flex-col">
Expand All @@ -197,12 +191,9 @@ export function ConnectInstructions({
</TabsContent>

<TabsContent value="instrument" className={cn("overflow-auto mt-0", contentPadding)}>
{/* The snippet carries the org's ingest key in plain text and the dashboard
records itself with rrweb — block the block, not just the key. */}
<CodeBlock
code={interpolate(snippet.instrument)}
code={snippet.instrument.replace(/\{\{INGEST_URL\}\}/g, ingestUrl)}
language={snippet.label.toLowerCase()}
className={REPLAY_BLOCK_CLASS}
/>
</TabsContent>

Expand All @@ -214,7 +205,10 @@ export function ConnectInstructions({
Install the Maple skills, then run the prompt in Claude Code, Codex, or Cursor. The{" "}
<code className="rounded bg-muted px-1">maple-onboard</code> skill walks every service
in the repo, installs OpenTelemetry, wires traces / logs / metrics, and verifies the
bootstrap end-to-end.
bootstrap end-to-end. The prompt carries only the public key: set{" "}
<code className="rounded bg-muted px-1">MAPLE_INGEST_KEY</code> to your private key in
your <code className="rounded bg-muted px-1">.env</code> and deployment secrets
yourself.
</p>
<CodeBlock code={ONBOARD_SKILL_COMMAND} language="shell" />
<CodeBlock
Expand All @@ -235,8 +229,7 @@ export function ConnectInstructions({
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Credentials
</span>
<CopyableField value={ingestUrl} label="Ingest endpoint" />
<CopyableField value={apiKey || "Loading…"} label="API key" masked />
<ConnectCredentials />
</div>
{tabs}
</div>
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/ingest/onboard-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ describe("onboardSkillPrompt", () => {
[
"Install Maple in this repo using the maple-onboard skill.",
"My ingest endpoint is https://ingest.eu.maple.dev.",
"My ingest key is maple_pk_abc.",
"My public ingest key for browser and mobile code is maple_pk_abc.",
"Server code reads my private key from MAPLE_INGEST_KEY, which I set in my environment or .env myself.",
].join("\n"),
)
})

it("uses a placeholder while the key is loading", () => {
expect(onboardSkillPrompt("https://ingest.maple.dev", "")).toContain(
"My ingest key is <your-ingest-key>.",
)
it("drops the public key line while the key is loading, so the skill falls back to MAPLE_TEST", () => {
const prompt = onboardSkillPrompt("https://ingest.maple.dev", "")
expect(prompt).not.toContain("public ingest key")
expect(prompt).toContain("MAPLE_INGEST_KEY")
})
})

Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/ingest/onboard-skill.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Installs the whole skills folder: maple-onboard reads its per-language companion skills.
export const ONBOARD_SKILL_COMMAND = "bunx skills add MapleTechLabs/maple/skills --skill '*'"

// The endpoint carries the org's region; the skill uses it verbatim.
export function onboardSkillPrompt(ingestUrl: string, ingestKey: string): string {
// The endpoint carries the org's region; the skill uses it verbatim. The prompt is pasted into an
// AI tool, so it carries only the public key: the user sets the private key in their own env.
// Without a public key (still loading, or no access) the skill inlines MAPLE_TEST for the user to swap.
export function onboardSkillPrompt(ingestUrl: string, publicKey: string): string {
return [
"Install Maple in this repo using the maple-onboard skill.",
`My ingest endpoint is ${ingestUrl}.`,
`My ingest key is ${ingestKey || "<your-ingest-key>"}.`,
...(publicKey ? [`My public ingest key for browser and mobile code is ${publicKey}.`] : []),
"Server code reads my private key from MAPLE_INGEST_KEY, which I set in my environment or .env myself.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

Prompt sends servers to MAPLE_INGEST_KEY, but the shipped skill says inline the key

F1 · Warning · convention

The prompt tells the agent server code reads the private key from MAPLE_INGEST_KEY set by the user, and all six instrument snippets assert MAPLE_INGEST_KEY is set, warn and skip the exporter. The shipped rule the prompt invokes, skills/maple-onboard/SKILL.md:34 and :185, still says the ingest key is inlined in the bootstrap source and lists env-var indirection as a failure mode ("env-var indirection just adds deploy-time failure modes for no gain"), and its public-key path (:43) inlines it in every bootstrap file, servers included. Until #1115 lands with these snippets, the agent gets one instruction from the prompt and the opposite from the skill it must read, so a user who pastes the prompt can still end up with the key inlined in server source.

Land the skill change (`skills/maple-onboard/SKILL.md` Step 0 and Hard rules) in the same release as these snippets, or keep the prompt's server line only once that rule is on main.
🤖 Prompt to fix with an AI agent
In `apps/web/src/components/ingest/onboard-skill.ts:12`: Prompt sends servers to `MAPLE_INGEST_KEY`, but the shipped skill says inline the key.

The prompt tells the agent server code reads the private key from `MAPLE_INGEST_KEY` set by the user, and all six instrument snippets assert `MAPLE_INGEST_KEY` is set, warn and skip the exporter. The shipped rule the prompt invokes, `skills/maple-onboard/SKILL.md:34` and `:185`, still says the ingest key is inlined in the bootstrap source and lists env-var indirection as a failure mode ("env-var indirection just adds deploy-time failure modes for no gain"), and its public-key path (`:43`) inlines it in every bootstrap file, servers included. Until #1115 lands with these snippets, the agent gets one instruction from the prompt and the opposite from the skill it must read, so a user who pastes the prompt can still end up with the key inlined in server source.

Suggested fix: Land the skill change (`skills/maple-onboard/SKILL.md` Step 0 and Hard rules) in the same release as these snippets, or keep the prompt's server line only once that rule is on main.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

].join("\n")
}
13 changes: 13 additions & 0 deletions apps/web/src/components/quick-start/sdk-snippets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest"

import { sdkSnippets } from "./sdk-snippets"

describe("sdkSnippets", () => {
it.each(sdkSnippets.map((s) => [s.language, s.instrument]))(
"%s reads the private key from MAPLE_INGEST_KEY instead of inlining a key",
(_, instrument) => {
expect(instrument).toContain("MAPLE_INGEST_KEY")
expect(instrument).not.toMatch(/\{\{API_KEY\}\}|maple_[ps]k_[a-z0-9]/)
},
)
})
112 changes: 66 additions & 46 deletions apps/web/src/components/quick-start/sdk-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";

export function register() {
// Your private key (maple_sk_…) from Settings → Ingestion, set in .env and your deployment's secrets.
const ingestKey = process.env.MAPLE_INGEST_KEY;
if (!ingestKey) {
console.warn("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled");
return;
}
const headers = { Authorization: \`Bearer \${ingestKey}\` };

registerOTel({
serviceName: "my-next-app",
attributes: { environment: "production" },
traceExporter: {
url: "{{INGEST_URL}}/v1/traces",
headers: { Authorization: "Bearer {{API_KEY}}" },
},
traceExporter: { url: "{{INGEST_URL}}/v1/traces", headers },
logRecordProcessor: new SimpleLogRecordProcessor(
new OTLPLogExporter({
url: "{{INGEST_URL}}/v1/logs",
headers: { Authorization: "Bearer {{API_KEY}}" },
})
new OTLPLogExporter({ url: "{{INGEST_URL}}/v1/logs", headers })
),
});
}`,
Expand All @@ -60,25 +62,23 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";

const sdk = new NodeSDK({
// Without this your service shows up as "unknown_service:node".
serviceName: "my-service",
traceExporter: new OTLPTraceExporter({
url: "{{INGEST_URL}}/v1/traces",
headers: { Authorization: "Bearer {{API_KEY}}" },
}),
logRecordProcessors: [
new SimpleLogRecordProcessor(
new OTLPLogExporter({
url: "{{INGEST_URL}}/v1/logs",
headers: { Authorization: "Bearer {{API_KEY}}" },
})
),
],
instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();`,
// Your private key (maple_sk_…) from Settings → Ingestion, set in .env and your deployment's secrets.
const ingestKey = process.env.MAPLE_INGEST_KEY;

if (!ingestKey) {
console.warn("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled");
} else {
const headers = { Authorization: \`Bearer \${ingestKey}\` };
new NodeSDK({
// Without this your service shows up as "unknown_service:node".
serviceName: "my-service",
traceExporter: new OTLPTraceExporter({ url: "{{INGEST_URL}}/v1/traces", headers }),
logRecordProcessors: [
new SimpleLogRecordProcessor(new OTLPLogExporter({ url: "{{INGEST_URL}}/v1/logs", headers })),
],
instrumentations: [getNodeAutoInstrumentations()],
}).start();
}`,
},
{
language: "python",
Expand All @@ -89,6 +89,9 @@ sdk.start();`,
opentelemetry-exporter-otlp-proto-http \\
opentelemetry-instrumentation`,
instrument: `# tracing.py
import logging
import os

from opentelemetry import trace
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
Expand All @@ -97,11 +100,17 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExport

# Without a service name your service shows up as "unknown_service".
provider = TracerProvider(resource=Resource.create({SERVICE_NAME: "my-service"}))
exporter = OTLPSpanExporter(
endpoint="{{INGEST_URL}}/v1/traces",
headers={"Authorization": "Bearer {{API_KEY}}"},
)
provider.add_span_processor(BatchSpanProcessor(exporter))

# Your private key (maple_sk_…) from Settings → Ingestion, set in .env and your deployment's secrets.
ingest_key = os.environ.get("MAPLE_INGEST_KEY")
if ingest_key:
exporter = OTLPSpanExporter(
endpoint="{{INGEST_URL}}/v1/traces",
headers={"Authorization": f"Bearer {ingest_key}"},
)
provider.add_span_processor(BatchSpanProcessor(exporter))
else:
logging.warning("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
trace.set_tracer_provider(provider)

# Create a tracer and send a test span
Expand All @@ -122,6 +131,7 @@ with tracer.start_as_current_span("hello-maple"):
import (
"context"
"log"
"os"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
Expand All @@ -133,19 +143,23 @@ import (
func main() {
ctx := context.Background()

exporter, err := otlptracehttp.New(ctx,
// Without a service name your service shows up as "unknown_service".
res := resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("my-service"))
opts := []trace.TracerProviderOption{trace.WithResource(res)}

// Your private key (maple_sk_…) from Settings → Ingestion, set in .env and your deployment's secrets.
if ingestKey := os.Getenv("MAPLE_INGEST_KEY"); ingestKey == "" {
log.Println("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
} else if exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL("{{INGEST_URL}}/v1/traces"),
otlptracehttp.WithHeaders(map[string]string{
"Authorization": "Bearer {{API_KEY}}",
}),
)
if err != nil {
log.Fatal(err)
otlptracehttp.WithHeaders(map[string]string{"Authorization": "Bearer " + ingestKey}),
); err != nil {
log.Println("Maple telemetry export is disabled:", err)
} else {
opts = append(opts, trace.WithBatcher(exporter))
}

// Without a service name your service shows up as "unknown_service".
res := resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("my-service"))
tp := trace.NewTracerProvider(trace.WithBatcher(exporter), trace.WithResource(res))
tp := trace.NewTracerProvider(opts...)
defer tp.Shutdown(ctx)
otel.SetTracerProvider(tp)

Expand All @@ -168,7 +182,7 @@ func main() {
import { Maple } from "@maple-dev/effect-sdk"
import { Effect } from "effect"

// Auto-detects MAPLE_ENDPOINT, MAPLE_INGEST_KEY,
// Auto-detects MAPLE_ENDPOINT, MAPLE_INGEST_KEY (your private key, maple_sk_…),
// commit SHA, and deployment environment from env vars
const TracerLive = Maple.layer({
serviceName: "my-effect-app",
Expand All @@ -190,10 +204,16 @@ Effect.runPromise(
iconKey: "otel",
install: `# Use your language's OpenTelemetry SDK
# See https://opentelemetry.io/docs/languages/ for installation`,
instrument: `# Configure via environment variables
export OTEL_EXPORTER_OTLP_ENDPOINT="{{INGEST_URL}}"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer {{API_KEY}}"
instrument: `# Configure via environment variables. MAPLE_INGEST_KEY holds your private key
# (maple_sk_…) from Settings → Ingestion, set in .env and your deployment's secrets.
export OTEL_SERVICE_NAME="my-service"
if [ -n "$MAPLE_INGEST_KEY" ]; then
export OTEL_EXPORTER_OTLP_ENDPOINT="{{INGEST_URL}}"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $MAPLE_INGEST_KEY"
else
echo "MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled" >&2
export OTEL_SDK_DISABLED=true
fi

# Then run your application with your language's OTel SDK enabled`,
},
Expand Down
Loading