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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ tracer.TrackTool(raindrop.TrackToolOptions{

- `WithWriteKey(string)`: Sets the Raindrop write key. When empty and no local Workshop URL resolves, the client becomes a no-op.
- `WithEndpoint(string)`: Overrides the base API endpoint. Defaults to `https://api.raindrop.ai/v1/`.
- `WithProjectID(string)`: Scopes telemetry to a Raindrop project by attaching the `X-Raindrop-Project-Id` header to every outbound request. The value is trimmed and validated against `^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`. When unset, no header is sent and the backend routes events to the org's default project; an invalid value is ignored with a logged warning (no header is sent) so a misconfiguration never breaks ingestion.
- `WithLocalWorkshopURL(string)`: Pins the local Workshop daemon URL, suppressing env vars and the auto-detect probe. Pass an empty string to revert to inherit-from-env behavior.
- `WithDisableLocalWorkshop()`: Opts out of the local mirror entirely, even if `RAINDROP_LOCAL_DEBUGGER` / `RAINDROP_WORKSHOP` is set or a daemon is listening on the default port.
- `WithDebug(bool)`: Enables debug logging.
Expand All @@ -250,6 +251,27 @@ tracer.TrackTool(raindrop.TrackToolOptions{
- `WithCloseTimeout(time.Duration)`: Sets the hard deadline for `Close()`'s final flush; once it passes, in-flight sends are aborted and remaining payloads are dropped. Defaults to `10s`. Non-positive values are ignored.
- `WithLogger(*slog.Logger)`: Uses a custom structured logger.

## Routing To A Project

By default, telemetry lands in your org's `default` project. Pass
`WithProjectID` to route every event, signal, identify call, and trace to a
named project instead:

```go
client, err := raindrop.New(
raindrop.WithWriteKey("rk_..."),
raindrop.WithProjectID("checkout-bot"),
)
```

When set to a valid slug, the `X-Raindrop-Project-Id` header is attached to
every outbound request (including the local Workshop mirror). When unset, no
header is sent and the backend falls back to the default project, so existing
callers are unaffected. The slug is trimmed and validated against
`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`; an invalid value is ignored with a
logged warning and no header is sent, so a misconfigured project ID can never
break telemetry shipping.

## Local Workshop Mirror

When a local Workshop daemon URL resolves, every cloud-bound POST is also mirrored to the local URL so events show up in a local Workshop instance during development.
Expand All @@ -272,6 +294,7 @@ The local POST uses a 2s timeout, no retries, and errors are logged at `debug` l
- Signal payloads go to `/signals/track`.
- User identify payloads go to `/users/identify`.
- Traces are sent as OTLP JSON to `/traces`.
- When `WithProjectID` is set to a valid slug, every outbound request carries the `X-Raindrop-Project-Id` header; otherwise the header is omitted.
- `Begin()`/`Finish()` is the recommended flow for new code.
- `ResumeInteraction()` is only for recovering an active interaction handle in the same process.
- Empty `writeKey` with no local Workshop URL resolved disables all shipping without raising errors.
Expand Down
42 changes: 42 additions & 0 deletions http.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log/slog"
"math/rand"
"net/http"
"regexp"
"strconv"
"strings"
"time"
Expand All @@ -26,12 +27,41 @@ const (
// maxRetryAfterDelay caps how long a server-provided Retry-After header
// can delay the next attempt.
maxRetryAfterDelay = 30 * time.Second

// projectIDHeader routes telemetry to a specific Raindrop project when set.
projectIDHeader = "X-Raindrop-Project-Id"
)

// projectIDSlugPattern bounds a project_id to a DNS-label-style slug.
var projectIDSlugPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)

// normalizeProjectID trims and validates a configured project_id. An empty or
// whitespace-only value yields "" (no header is sent). An invalid value is
// dropped with a warning rather than risking an ingest-time HTTP 400, so a
// misconfigured project_id can never break telemetry shipping.
func normalizeProjectID(raw string, logger *slog.Logger) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if !projectIDSlugPattern.MatchString(trimmed) {
if logger != nil {
logger.Warn(
"raindrop: ignoring invalid project_id; no X-Raindrop-Project-Id header will be sent",
"project_id", trimmed,
"pattern", projectIDSlugPattern.String(),
)
}
return ""
}
return trimmed
}

type retryingHTTPClient struct {
baseURL string
localBaseURL string
writeKey string
projectID string
client *http.Client
localClient *http.Client
debug bool
Expand Down Expand Up @@ -67,6 +97,7 @@ func newRetryingHTTPClient(cfg config, localBaseURL string) *retryingHTTPClient
baseURL: cfg.endpoint,
localBaseURL: localBaseURL,
writeKey: cfg.writeKey,
projectID: cfg.projectID,
client: cfg.httpClient,
localClient: localClient,
debug: cfg.debug,
Expand Down Expand Up @@ -142,6 +173,7 @@ func (c *retryingHTTPClient) postOnce(ctx context.Context, url string, payload [
}
req.Header.Set("Authorization", "Bearer "+c.writeKey)
req.Header.Set("Content-Type", "application/json")
c.setProjectIDHeader(req)

resp, err := c.client.Do(req)
if err != nil {
Expand Down Expand Up @@ -207,6 +239,7 @@ func (c *retryingHTTPClient) postLocalMirror(ctx context.Context, path string, p
req.Header.Set("Authorization", "Bearer "+c.writeKey)
}
req.Header.Set("Content-Type", "application/json")
c.setProjectIDHeader(req)
resp, err := c.localClient.Do(req)
if err != nil {
c.debugMirror("local mirror POST failed", "error", err, "url", url)
Expand All @@ -225,6 +258,15 @@ func (c *retryingHTTPClient) debugMirror(msg string, args ...any) {
c.logger.Debug(msg, args...)
}

// setProjectIDHeader attaches the project routing header when a project_id is
// set. Values are trimmed and validated once at New(), so projectID is empty
// here for blank or invalid input and no header is sent.
func (c *retryingHTTPClient) setProjectIDHeader(req *http.Request) {
if c.projectID != "" {
req.Header.Set(projectIDHeader, c.projectID)
}
}

func (c *retryingHTTPClient) retryDelay(retryNumber int, previous error) time.Duration {
if statusErr, ok := previous.(*httpStatusError); ok && statusErr.RetryAfter > 0 {
// Clamp server-controlled values: an arbitrary Retry-After (hours,
Expand Down
15 changes: 15 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Option func(*config) error
type config struct {
writeKey string
endpoint string
projectID string
localWorkshop LocalWorkshopConfig
autoDetectLocal bool
debug bool
Expand Down Expand Up @@ -69,6 +70,20 @@ func WithEndpoint(endpoint string) Option {
}
}

// WithProjectID scopes all telemetry to a Raindrop project. When set to a
// valid slug, every outbound request carries the X-Raindrop-Project-Id
// header so the ingest boundary routes events to the named project; when
// unset, no header is sent and the backend falls back to the org's default
// project (fully backward compatible). The value is trimmed and validated
// against ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$ at New(); an invalid value
// is ignored with a warning rather than risking an ingest-time rejection.
func WithProjectID(projectID string) Option {
return func(cfg *config) error {
cfg.projectID = projectID
return nil
}
}

// WithLocalWorkshopURL pins the local Workshop daemon URL, suppressing env
// vars and the auto-detect probe. Pass an empty string to revert to the
// inherit-from-env default behavior.
Expand Down
Loading
Loading