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
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,52 @@ The pattern the user flags most: **announcing a quantity + a vague nominalizatio
| "Both workflow types are supported." | flat passive announcement | "KloudMate supports both Standard and Express workflows." |
| "…with three tabs: Executions, Metrics, Configuration." | needless count | "…with tabs for Executions, Metrics, and Configuration." |

### The reference sample

[`src/content/docs/docs/rum/instrumentation-guide/custom-events.mdx`](src/content/docs/docs/rum/instrumentation-guide/custom-events.mdx) and the **Filter the data** section of [`rum-interface.mdx`](src/content/docs/docs/rum/rum-interface.mdx) were rewritten by the maintainer as the model for this repo. Read them before writing a new page and match what they do:

- **Second person, explaining to a competent reader.** "Every attribute you send with an event can be used as both a group-by key and a filter." Not a terse spec line, not a lecture.
- **"For example," carries the concrete case.** State the rule, then show it: "For example, `checkout_completed` is a good event name."
- **Complete sentences with the consequence spelled out.** "If you send `4999` as a string, it will be stored as text, so a filter such as `items > 2` won't work as expected."
- **Contractions throughout:** don't, can't, won't, it's, you've.
- **Bold for the concept being contrasted,** not for decoration: "The event name should describe **what happened**, not the specific object or user it happened to."

### Don't describe the plumbing

The reader has the product open. Describing what a screen does is fine when it changes how they read the data ("the filter bar tells you what it is matching", "it is shown as struck through with an explanation"). Describing how it is built is not.

Cut on sight:

- **Control plumbing:** "holds the choice in the URL", "URL backed, so a breakdown is shareable", "on the view-switcher row", "sits beside the bar".
- **Quoted on-screen message strings, and "the tab says so".** State the rule that produces the message. The reader will read the message when they hit it.
- **Menu geography:** "the **by** menu lists them under **Payload**, next to **Attributes**".
- **Implementation:** which attribute map a value lands in, what the query compiles to, why the design went that way.

| Don't write | Write instead |
|---|---|
| "Each view has its own **by** control and holds the choice in the URL, so a breakdown can be shared." | "You can group by any attribute your application sends in the event payload, in both Analyze and Raw events." |
| "When nothing in the range carries the key, the tab falls back and says so: `Nothing in this range carries category…`" | "If you group by a key that isn't present in the selected range, the grouping falls back to event name." |
| "Journeys resolves it over whole sessions through a semi-join." | "In a funnel, `cart_value > 100` selects sessions that emitted at least one matching event." |

### Sentence shape

One idea per sentence, subject first, verb early. Any clause the reader has to unpack reads as machine writing, and so does a technical thing described in literary paraphrase.

| Don't write | Why it reads as AI | Write instead |
|---|---|---|
| "Group by anything the events carried." | literary paraphrase where a technical noun exists | "Group by any attribute from the event payload." |
| "Events that never carried the attribute group under **No category**, not a blank row." | front-loaded relative clause, then an "X, not Y" tail | "Events that were sent without the attribute are collected into a single group. When you group by `category`, that group appears as **No category**." |
| "…so it marks an instrumentation gap rather than a category." | "X rather than Y", contrasting against something nobody proposed | "It tells you how many events were sent without the attribute at all, so it's really a measure of missing instrumentation." |
| "Over time charts the 50 largest groups. Group by **User** and most of them are missing." | pronoun with no clear antecedent; "missing" implies a bug | "If you group by an attribute with thousands of distinct values, such as a user ID, most of them won't appear on the chart." |
| "**Events** and **Journeys** answer it." · "**Errors** cannot answer one." | screens do not answer, know, want, or try; anthropomorphism reads as drama | "**Events** and **Journeys** support payload filters." · "**Errors**, **Pages**, and **Releases** don't support payload filters." |
| "The group counts missing instrumentation. Exclude it to compare the real values. Every panel on the tab drops it." | telegraphic fragments; plain is not the same as clipped | "Excluding it removes those events from every panel on the tab, which lets you compare the real categories against each other." |

Name things with the term the product and the reader both use. "Anything the events carried" is a phrase; "an attribute from the event payload" is the thing.

Plain does not mean cryptic. Cutting a sentence to four words that the reader then has to decode is a worse failure than the padding it replaced. Write the full sentence, then delete only what carries no meaning.

Detail has a bar too: state the behavior, not its operator-by-operator mechanics. "Selecting more than one value on a field matches any of them" is the rule; "two `=` picks become **In**, two `!=` picks become **Not in**, and a second `>` replaces the first" is a spec dump.

### Tone (AGENTS.md §2 has the full word lists; these are the repeat offenders)

- **Em dashes for asides** → period, comma, colon, or parentheses.
Expand Down
5 changes: 3 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export default defineConfig({
// KloudMate RUM. `init` runs from `onload` so the bundle is fully executed
// before it is called; the rolling /v2/ path picks up SDK patches without an
// edit here. Session recording is off, so this collects page performance and
// errors only.
// errors only. The `km-rum-ready` event lets UserNav.astro attach the
// signed-in user's userId/userEmail even though this bundle loads async.
head: [
{
tag: 'script',
Expand All @@ -91,7 +92,7 @@ export default defineConfig({
version: '',
sampleRate: 1.0,
sessionRecorder: { enabled: false },
})`,
}); document.dispatchEvent(new Event('km-rum-ready'))`,
},
},
],
Expand Down
3 changes: 3 additions & 0 deletions src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import starlightConfig from 'virtual:starlight/user-config';
import options from 'virtual:starlight-theme-nova/user-config';
import MobileMenuToggle from 'starlight-theme-nova/components/MobileMenuToggle.astro';

import UserNav from './UserNav.astro';

const route = Astro.locals.starlightRoute;
const currentPath = normalizePath(Astro.url.pathname);
const siteTitleHref = withBase(route.siteTitleHref);
Expand Down Expand Up @@ -200,6 +202,7 @@ function getI18nText(value, activeRoute) {
<Search />
</div>
<div class="hidden items-center gap-2 md:flex print:hidden">
<UserNav />
<SocialIcons />
<LanguageSelect />
<ThemeSelect />
Expand Down
132 changes: 132 additions & 0 deletions src/components/UserNav.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
/**
* Account button for the header: "Log in" by default, "Go to Dashboard" when a
* KloudMate session exists.
*
* The docs share the platform's HTTP-only session cookie (scoped to
* `.kloudmate.com`), so `GET https://api.<domain>/users/me` with credentials
* resolves the signed-in user without any auth flow here. The API's CORS
* allowlist already reflects any `*.<domain>` origin with credentials.
* The same result is attached to RUM spans as `userId` / `userEmail`, which is
* what the RUM sessions UI reads (anonymous sessions otherwise).
*
* Off-domain (localhost, previews) there is no shared cookie, so the script
* skips the network call and keeps the static logged-out default.
*/
---

<a class="user-cta" data-user-cta href="https://app.kloudmate.com/login">Log in</a>

<script>
const CACHE_KEY = 'km-docs-user';
// Per-tab cache so plain page navigation doesn't refetch /users/me.
const RESULT_TTL = 15 * 60 * 1000;
const ERROR_TTL = 5 * 60 * 1000;

// The API and app live on the docs' own parent domain. No match means no
// shared cookie, so don't call the API at all.
const parentDomain = ['kloudmate.com', 'kloudmate.dev'].find(
(domain) => location.hostname === domain || location.hostname.endsWith(`.${domain}`)
);
const appOrigin = `https://app.${parentDomain ?? 'kloudmate.com'}`;

function readCache() {
try {
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? '');
if (cached && Date.now() - cached.t < (cached.err ? ERROR_TTL : RESULT_TTL)) {
return cached;
}
} catch {
// Absent or malformed cache: fall through to a fresh fetch.
}
return null;
}

async function fetchUser() {
let user = null;
let err = false;
try {
const res = await fetch(`https://api.${parentDomain}/users/me`, {
credentials: 'include',
});
if (res.ok) {
const profile = await res.json();
if (profile?.id) {
user = { id: profile.id, name: profile.name ?? '', email: profile.email ?? '' };
}
} else if (res.status !== 401 && res.status !== 403) {
// 401/403 definitively means logged out; anything else may be transient,
// so cache it for a shorter time.
err = true;
}
} catch {
err = true;
}
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), user, err }));
} catch {
// Storage full or blocked: skip caching, the next page just refetches.
}
return user;
}

function render(user) {
const cta = document.querySelector<HTMLAnchorElement>('[data-user-cta]');
if (!cta) return;
if (user) {
cta.textContent = 'Go to Dashboard';
cta.href = `${appOrigin}/`;
} else {
cta.textContent = 'Log in';
cta.href = `${appOrigin}/login`;
}
}

// The RUM bundle loads async from the CDN, so it may not exist yet when the
// user resolves. `km-rum-ready` is dispatched by the init snippet in
// astro.config.mjs right after KloudMateRum.init(). setUser maps id/email to
// the userId/userEmail span attributes the RUM sessions UI reads.
function tagRum(user) {
if (!user) return;
const apply = () =>
(window as any).KloudMateRum?.setUser({ id: user.id, email: user.email });
if ((window as any).KloudMateRum) {
apply();
} else {
document.addEventListener('km-rum-ready', apply, { once: true });
}
}

const cached = readCache();
if (cached) {
render(cached.user);
tagRum(cached.user);
} else if (parentDomain) {
fetchUser().then((user) => {
render(user);
tagRum(user);
});
}
</script>

<style>
.user-cta {
display: inline-flex;
align-items: center;
background-color: var(--sl-color-bg-accent);
color: var(--sl-color-text-invert);
font-size: var(--sl-text-sm);
font-weight: 600;
line-height: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.15s ease;
}

.user-cta:hover,
.user-cta:focus-visible {
background-color: color-mix(in srgb, var(--sl-color-bg-accent) 85%, black);
}
</style>
46 changes: 40 additions & 6 deletions src/content/docs/docs/platform/settings/notification-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ Optionally, you can download the KloudMate logo from the link provided on the sc

![image](./images/webhooks-1.png)

#### Customize the Request Body and Headers

By default, a webhook sends KloudMate's standard JSON payload, which some receivers reject. Google Chat, for example, accepts only a message object like `{"text": "..."}`, with its auth token in the webhook URL, so no header or secret is needed. The same `{"text": ...}` body also works for Slack incoming webhooks and Microsoft Teams. A webhook channel can rewrite its outgoing body and add headers to match.

In the webhook channel editor, click **Customize request body and headers** to expand the options.

**Request body** takes JSON with Liquid `{{ }}` placeholders inside string values, filled from the event. Leave it empty to send the default payload. The **Examples** menu fills it with a starter template, and [Notification Payloads](#notification-payloads) lists the fields each event type exposes.

**Custom headers** are key/value pairs sent with every request. `Content-Type` and `X-KM-Signature` are reserved and set automatically.

**Test** previews the rendered body against a sample event. The **Event type** list picks which event to sample.

#### Webhook Signature Validation

When KloudMate sends a webhook to your configured URL, it includes an `X-KM-Signature` HTTP header. This signature is an HMAC-SHA256 hash generated using the payload body and the **Secret** you provided during configuration.
Expand Down Expand Up @@ -152,18 +164,32 @@ Here are examples of how to perform this validation in common programming langua

#### Notification Payloads

Every webhook is delivered as a single `POST` with a JSON body and `Content-Type: application/json`. The body you receive is exactly the bytes that were HMAC-signed (see above), so verify the signature against the raw body before parsing.
Every webhook is a single `POST` with a JSON body and `Content-Type: application/json`. Without a custom request body, this is what KloudMate sends, and a template reads its fields from the same payload. Receivers that verify the signature should hash the raw body before parsing it.

The payload shape depends on which KloudMate feature triggered the notification:
Each notification has an event type. The **Event type** picker in the channel editor lists all of them, and a template can reference any field from the matching payload.

- **Alert group** notifications carry a top-level `event` field (`opened`, `appended`, `resolved`, or `rca_completed`) that identifies the lifecycle stage.
- The other payloads have no `event` field — identify them by their distinctive top-level keys: `slo_name` (SLO burn rate), `monitor_id` (synthetic monitor), `serviceName` (new issue), and `investigationId` (AI investigation).
| Event type | Payload |
| --- | --- |
| `alarm_group.opened` | Alert group, `event` is `opened` |
| `alarm_group.appended` | Alert group, `event` is `appended` |
| `alarm_group.resolved` | Alert group, `event` is `resolved` |
| `alarm_group.rca_completed` | Alert group, `event` is `rca_completed` |
| `issue.created` | New issue |
| `slo.burn_rate.firing` | SLO burn rate |
| `slo.burn_rate.resolved` | SLO burn rate |
| `synthetics.incident.created` | Synthetic monitor |
| `synthetics.incident.resolved` | Synthetic monitor |
| `investigation.completed` | AI investigation |

The tabs below show one representative example of each payload, with notes on how the variants differ.
The alarm group payload sets a top-level `event` field to the lifecycle stage. The other payloads carry no `event` field. A receiver tells them apart by their distinctive top-level keys: `slo_name` for SLO burn rate, `monitor_id` for a synthetic monitor, `serviceName` for a new issue, and `investigationId` for an AI investigation.

The tabs below show one representative example of each payload, the fields most useful in a template, and notes on how the variants differ.

<Tabs>
<TabItem label="Alert group">
Sent when an [alert group](../../../alerts/) opens, appends new alerts, resolves, or completes a root-cause analysis. The `event` field tells you which one. `workspace` identifies the workspace (tenant) the alert belongs to. `group` carries the group's id, title, state, severity, `labels`, `annotations`, and its link in KloudMate. `rules` lists each alarm rule in the group; each rule's `instances` array holds every matched instance with its own `labels`, `annotations`, and `state`, and `commonLabels`/`commonAnnotations` are the values shared by all of them. `totals` and each rule's `counts` are keyed by state: `Firing`, `Resolved`, `No Data`, `Error`, `Normal`. `group.mode` is `"group"` for a correlated group or `"standalone"` for a single alarm rule.
**Event types:** `alarm_group.opened`, `alarm_group.appended`, `alarm_group.resolved`, `alarm_group.rca_completed`. **Fields for templates:** `group.title`, `group.severity`, `group.state`, `group.url`, `group.opened_at`, `totals.Firing`, `rca.summary`, `rules[0].alarm_name`, `rules[0].instances[0].labels.<key>`.

Sent when an [alert group](../../../alerts/) opens, appends new alerts, resolves, or completes a root-cause analysis. The `event` field names which one. `workspace` identifies the workspace (tenant) the alert belongs to. `group` carries the group's id, title, state, severity, `labels`, `annotations`, and its link in KloudMate. `rules` lists each alarm rule in the group; each rule's `instances` array holds every matched instance with its own `labels`, `annotations`, and `state`, and `commonLabels`/`commonAnnotations` are the values shared by all of them. `totals` and each rule's `counts` are keyed by state: `Firing`, `Resolved`, `No Data`, `Error`, `Normal`. `group.mode` is `"group"` for a correlated group or `"standalone"` for a single alarm rule.

```json
{
Expand Down Expand Up @@ -237,6 +263,8 @@ The tabs below show one representative example of each payload, with notes on ho
```
</TabItem>
<TabItem label="New issue">
**Event type:** `issue.created`. **Fields for templates:** `subject`, `serviceName`, `severity`, `errorMessage`, `occurrence`, `lastOccurrenceAt`, `actionUrl`, and `actionLabel`.

Sent when a new [issue](../../../issues/) is created for a service.

```json
Expand All @@ -253,6 +281,8 @@ The tabs below show one representative example of each payload, with notes on ho
```
</TabItem>
<TabItem label="SLO burn rate">
**Event types:** `slo.burn_rate.firing`, `slo.burn_rate.resolved`. **Fields for templates:** `subject`, `slo_name`, `severity_tier`, `threshold`, `short_window`, `long_window`, `observed_short_burn`, `observed_long_burn`, `status_line`, and `actionUrl`.

Sent when an [SLO burn-rate alert](../../../reliability/burn-rate-alerts/) starts firing or resolves. `threshold` and the observed burn rates are formatted with a trailing `×` (an infinite rate renders as `∞`); windows render as `5m`, `1h`, etc.

```json
Expand All @@ -275,6 +305,8 @@ The tabs below show one representative example of each payload, with notes on ho
On resolve, `subject` becomes `"✅ SLO burn-rate resolved: Checkout availability"` and `status_line` becomes a duration, e.g. `"Fired for 17 minutes"`. `severity_tier` is one of `critical`, `high`, `medium`, or `low`.
</TabItem>
<TabItem label="Synthetic monitor">
**Event types:** `synthetics.incident.created`, `synthetics.incident.resolved`. **Fields for templates:** `subject`, `name`, `target`, `cause`, `start_time`, `duration`, `monitor_id`, `actionUrl`, and `actionLabel`.

Sent when a [synthetic monitor](../../../synthetic/) goes down or recovers.

```json
Expand All @@ -294,6 +326,8 @@ The tabs below show one representative example of each payload, with notes on ho
On recovery, `subject` becomes `"🟢 Monitor is UP: API health"`, `duration` is populated (e.g. `"15 minutes"`), and an `end_time` field is added.
</TabItem>
<TabItem label="AI investigation">
**Event type:** `investigation.completed`. **Fields for templates:** `subject`, `title`, `rootCauseAnalysisText`, `completedAt`, `actionUrl`, and `actionLabel`.

Sent when an [AI investigation](../../../kloudmate-assistant/investigations/) completes. `rootCauseAnalysisText` is plain text, truncated to 8000 characters.

```json
Expand Down
Loading
Loading