fingerly

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

README

fingerly-go

Server-side event reads, webhook verification and a same-origin proxy for Go. Go 1.22 or newer. No dependencies outside the standard library.

go get github.com/fingerly-io/fingerly-go
import fingerly "github.com/fingerly-io/fingerly-go"

client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))

event, err := client.Events.Get(ctx, requestID)
page, err := client.Events.List(ctx, &fingerly.EventListParams{Level: "high", Limit: 50})

Event reads require fly_sk_{production|staging|development}_… keys. Requests go to https://api.fingerly.io; WithEndpoint overrides that for a local API. Public, proxy, management, malformed and unsupported keys are rejected before network access. Requests do not follow redirects. Get and List each perform one request and never retry.

New returns only a client, so it composes in a package-level var. An invalid key or option is reported by the first call, before anything is sent.

Every Client and Proxy in the process shares one connection pool, which keeps 256 idle connections per host and is replaced every five minutes so a DNS change is picked up. There is nothing to tune for a busy server. Pass WithHTTPClient or ProxyOptions.Transport only for a custom HTTP stack; an http.Client with a nil Transport still gets the shared pool.

Deadlines and cancellation

Every call takes a context.Context, and a Go request context governs the whole exchange, so the deadline covers reading the response body too. The client applies WithTimeout (10s by default) on top of whatever the caller's context already carries, whichever is sooner. Cancelling the context stops the request.

Errors

Any response outside 2xx, redirects included, returns an *APIError with Status, and optional Code, Details, RequestID and RetryAfter. The secret key is stripped from all of them, and the response body is never echoed. RetryAfter is the number of seconds from the Retry-After header, or nil when there was none; the SDK never retries a read itself.

var apiErr *fingerly.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusTooManyRequests && apiErr.RetryAfter != nil {
	wait := time.Duration(*apiErr.RetryAfter) * time.Second
	_ = wait // back off for at least this long
}

A 404 from Events.Get carries one of two codes, each with a sentinel, so neither needs a status check:

switch {
case errors.Is(err, fingerly.ErrEventPending):
	// Accepted but not recorded yet: retry after apiErr.RetryAfter seconds.
case errors.Is(err, fingerly.ErrEventNotFound):
	// Never issued for this key's environment, or past the 30-day retention.
}

A read that got no complete answer (refused, reset, timed out, or broken off mid-body) returns a *fingerly.ConnectionError, and errors.Is(err, fingerly.ErrTimeout) is true when a deadline passed. An answer over 4 MiB returns fingerly.ErrResponseTooLarge.

Printing a Client, Proxy or ProxyOptions with any fmt verb, slog or encoding/json shows the keys as [redacted].

Unscored events

Event.SuspectScore is a *int because nil means unscored, which must never be read as zero: a visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all. Scored() and Score() state that distinction at the call site.

score, ok := event.Score()
if !ok {
	// Unscored. Decide on something else; do not treat it as clean.
}

Event.Raw and EventPage.Raw keep the response body exactly as it arrived, so a field the API adds later is reachable without an SDK upgrade.

Read an event you just identified

event, err := client.Events.Get(ctx, requestID)

If the event is not recorded yet, the error matches ErrEventPending and its *APIError carries RetryAfter. That is not a verdict: try again after that many seconds, or verify asynchronously, for example from a webhook. The SDK never retries on its own. ErrEventNotFound means the request id was never issued or the event is past retention.

The archived Document arrives moments after the event itself, so check HasDocument rather than assuming it is there.

Verify a backend action

Read the event with a server-only key. Check that its tag matches your action, that it is recent, that its environment is correct, and that it was scored. Apply your own policy to the server's verdict. Do not authorize an action using scores or visitor identifiers supplied by the browser. Tags correlate events; they do not replace your application's authentication or replay protection.

package main

import (
	"errors"
	"log"
	"net/http"
	"os"
	"strconv"

	"github.com/fingerly-io/fingerly-go"
)

var client = fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))

// POST /verify/{request_id}
func verify(w http.ResponseWriter, r *http.Request) {
	event, err := client.Events.Get(r.Context(), r.PathValue("request_id"))
	var apiErr *fingerly.APIError
	switch {
	case errors.Is(err, fingerly.ErrEventPending) && errors.As(err, &apiErr):
		// Not decided yet, not fraud: ask the caller to come back.
		wait := 2
		if apiErr.RetryAfter != nil {
			wait = *apiErr.RetryAfter
		}
		w.Header().Set("Retry-After", strconv.Itoa(wait))
		http.Error(w, "verification pending", http.StatusServiceUnavailable)
		return
	case errors.Is(err, fingerly.ErrEventNotFound):
		http.Error(w, "unknown request", http.StatusForbidden)
		return
	case err != nil:
		http.Error(w, "verification unavailable", http.StatusBadGateway)
		return
	}
	// Apply your policy: event.Tag, event.OccurredAt, event.Scored() and
	// event.SuspectLevel.
	_, _ = w.Write([]byte(event.SuspectLevel))
}

func main() {
	http.HandleFunc("POST /verify/{request_id}", verify)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Webhooks

body, _ := io.ReadAll(r.Body)
ok := fingerly.VerifyWebhook(
	os.Getenv("FINGERLY_WEBHOOK_SECRET"),
	body,
	r.Header.Get("X-Fingerly-Timestamp"),
	r.Header.Get("X-Fingerly-Signature"),
)

Verify the exact raw bytes before decoding them. Signatures cover the timestamp and body. The default replay tolerance is five minutes; persist processed WebhookEvent.ID values to deduplicate deliveries. During signing-secret rotation, any matching signature among the first 32 in the comma-separated header is accepted. Malformed or missing input is a false result, never a panic.

WebhookEvent decodes the envelope every delivery shares and leaves Data raw, so a new event type needs no SDK upgrade.

Same-origin proxy

Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy on a path of your own site and those requests become first-party. The proxy key is what lets that hop declare the visitor's real address, origin and user agent; a public key alone never can, so nobody can spoof them.

Create a proxy in the dashboard (Integration > Proxy keys) and mount the handler at the path it issued, here /k7x2qm4a:

package main

import (
	"log"
	"net"
	"net/http"
	"os"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
		ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"),
		Path:     "/k7x2qm4a",
		ClientIP: func(r *http.Request) string {
			// The header your load balancer or CDN sets, else the TCP peer.
			if address := r.Header.Get("X-Real-IP"); address != "" {
				return address
			}
			host, _, _ := net.SplitHostPort(r.RemoteAddr)
			return host
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.Handle("/k7x2qm4a", proxy)
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Trust that header only when traffic can reach the server through that load balancer or CDN alone; otherwise a visitor can set it. Proxy implements http.Handler, so every router built on net/http takes it directly, and fiber through its adaptor:

router.Handle("/k7x2qm4a", proxy)                // gorilla/mux
r.Handle("/k7x2qm4a", proxy)                     // chi
router.Any("/k7x2qm4a", gin.WrapH(proxy))        // gin
e.Any("/k7x2qm4a", echo.WrapHandler(proxy))      // echo
app.All("/k7x2qm4a", adaptor.HTTPHandler(proxy)) // fiber

Then point the browser SDK at the same path. It posts both of its calls there, exactly as given:

const fingerly = await load({ apiKey: 'fly_pk_…', endpoints: '/k7x2qm4a' })

Unlike New, NewProxy reports configuration errors immediately: a proxy is wired up once at start-up, where an error is natural to handle.

What the proxy does

With Path set it answers only that path, matched exactly: a trailing or doubled slash, a dot segment, an encoded character, a different case or a path under it all get 404. Path is optional: leave it empty when the route already sends only that path to the handler, and behind http.StripPrefix, which changes the path the handler sees. It must be an absolute path such as /k7x2qm4a, or NewProxy returns an error. Only POST is forwarded; any other method gets 405.

Every forwarded request goes to one URL, https://api.fingerly.io/api/v1/ingest (APIVersion and Upstream change its version and origin). Event reads need a secret key and stay unreachable from a browser by construction.

The browser must present a public key from the same environment as the proxy key, or the request gets 401. Bodies over 1 MiB get 413. Only the content type, the visitor metadata Fingerly needs, and an Idempotency-Key are forwarded; customer cookies and authorization headers never are. The proxy reports itself upstream as go/0.1.1, which the dashboard shows per proxy.

One deadline, Timeout (5 seconds by default), covers the whole upstream exchange, body included, so a trickling answer cannot stretch it. When it passes the browser gets 504; any other upstream failure (refused, reset, DNS, a truncated or malformed answer, an answer over 4 MiB) gets 502. Both carry a short text/plain body and Cache-Control: no-store. The answer is read in full before anything is written, so a broken upstream never reaches the browser as a 200. A visitor who disconnects cancels the upstream call, except under fiber, whose adaptor has no disconnect signal, so the call runs to Timeout.

Timeout bounds the upstream call, not the visitor's upload: a client that trickles its request body holds a handler until your http.Server gives up, so set ReadHeaderTimeout and ReadTimeout on it. A bare http.ListenAndServe has neither.

Each failure is reported once: to ProxyOptions.OnError if you set it, otherwise as one line through slog.Default().

OnError: func(r *http.Request, failure *fingerly.ProxyError) {
	log.Printf("identification proxy answered %d: %v", failure.Status, failure.Err)
},

Responses are marked no-store, and the only upstream headers relayed back are Content-Type, X-Request-Id, RateLimit-Limit, RateLimit-Remaining and Retry-After, so nothing on the response names Fingerly. Use the path the dashboard issued: blockers look for words such as fingerprint, tracking, analytics and fingerly in the URL.

ClientIP is the one thing you must get right. Resolve it from a header your own infrastructure sets, such as your load balancer's or CDN's, and fall back to r.RemoteAddr. Never read the left-most X-Forwarded-For value: the visitor writes it. It must return exactly one IPv4 or IPv6 address. Anything else (empty, a list, a port) is a configuration error: the proxy answers 500 with "The client IP callback returned no valid address", calls nothing upstream, and reports it once.

Development and release preparation

gofmt -l .
go vet ./...
go test -race -cover ./...
go run tools/synccontract.go     # refresh the vendored contract snapshot

The vendored synthetic contract snapshot makes tests independent of sibling repositories. To update it from the integration workspace, set FINGERLY_CONTRACT_DIR to its contract directory. The snapshot's hashes are checked by go test, so there is no separate check step. Never put production secrets in fixtures.

Source structure

fingerly.go holds the Client and its options. transport.go performs authenticated requests and maps failures to *APIError. events.go owns the event reads and the list filters; models.go owns the types they decode into. webhooks.go verifies signatures. proxy.go is the same-origin handler. config.go validates every caller-supplied option: keys, origins, API versions and durations, all before any network access. errors.go holds the error type and its sentinel.

Documentation

Overview

Package fingerly is the server-side Fingerly SDK.

It reads stored identification events with a secret key, verifies signed webhooks, and serves the browser SDK's requests from a route on your own domain. It has no dependencies outside the standard library.

client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))
event, err := client.Events.Get(ctx, requestID)

Secret keys and webhook secrets belong in server-side code only. A browser must never see either.

Index

Examples

Constants

View Source
const DefaultWebhookTolerance = 5 * time.Minute

DefaultWebhookTolerance is how far a delivery's timestamp may be from now before it is refused as a replay.

View Source
const Version = "0.1.1"

Version is the SDK version, reported to the API on every request.

Variables

View Source
var ErrEventNotFound = errors.New("fingerly: event not found")

ErrEventNotFound matches a read for an event that does not exist: the request id was never issued for this key's environment, or the event is past retention. An event that is still being recorded matches ErrEventPending instead.

Compare with errors.Is; the concrete error is always an *APIError.

View Source
var ErrEventPending = errors.New("fingerly: event pending")

ErrEventPending matches a read for an event whose request id was accepted but which is not recorded yet (404 event_pending). It is not a verdict: try again after APIError.RetryAfter seconds, or verify asynchronously. The SDK never retries on its own.

Compare with errors.Is; the concrete error is always an *APIError.

View Source
var ErrInvalidRequestID = errors.New("fingerly: request id is not a Fingerly request id")

ErrInvalidRequestID is returned by EventsService.Get, before any request is made, for a request id that cannot name one event: the empty string, "." or "..". The id usually comes from the browser, so treat it as bad input.

View Source
var ErrResponseTooLarge = errors.New("fingerly: response body exceeds 4 MiB")

ErrResponseTooLarge is returned for an API answer over 4 MiB, which no event read produces. The body is not read past the limit.

View Source
var ErrTimeout = errors.New("fingerly: request timed out")

ErrTimeout matches a read whose deadline passed, whether the caller's or WithTimeout's. Compare with errors.Is.

Functions

func VerifyWebhook

func VerifyWebhook(secret string, payload []byte, timestamp, signature string, opts ...WebhookOption) bool

VerifyWebhook reports whether a delivery came from Fingerly and is recent.

Pass the exact bytes received, before any JSON decoding: re-encoding them changes the digest. During a signing-secret rotation Fingerly sends several comma-separated signatures, and any one of the first 32 matching is accepted.

body, _ := io.ReadAll(r.Body)
ok := fingerly.VerifyWebhook(secret, body,
	r.Header.Get("X-Fingerly-Timestamp"),
	r.Header.Get("X-Fingerly-Signature"))

Verification proves authenticity and freshness. It does not deduplicate, so persist processed WebhookEvent.ID values as well.

Malformed or missing input is a false result, never a panic.

Example

Verify the raw bytes before decoding them: re-encoding changes the digest.

package main

import (
	"encoding/json"
	"io"
	"net/http"
	"os"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	http.HandleFunc("/webhooks/fingerly", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "unreadable body", http.StatusBadRequest)
			return
		}
		if !fingerly.VerifyWebhook(
			os.Getenv("FINGERLY_WEBHOOK_SECRET"),
			body,
			r.Header.Get("X-Fingerly-Timestamp"),
			r.Header.Get("X-Fingerly-Signature"),
		) {
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}

		var event fingerly.WebhookEvent
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "unreadable event", http.StatusBadRequest)
			return
		}
		// Deduplicate on the event ID: a delivery may arrive more than once.
		enqueue(event.ID, body)
		w.WriteHeader(http.StatusNoContent)
	})
}

func enqueue(id string, body []byte) {}

Types

type APIError

type APIError struct {
	// Status is the HTTP status code.
	Status int
	// Code is the stable machine-readable code, such as "event_not_found".
	Code string
	// Details carries extra context; its shape depends on Code.
	Details any
	// RequestID is the server-side trace id to quote in support requests.
	RequestID string
	// RetryAfter is how many seconds the API asked the caller to wait, from
	// its Retry-After header, or nil when it sent none. The SDK never retries
	// a read itself.
	RetryAfter *int
}

APIError is returned for any non-2xx response from the Fingerly API.

The message carries only the HTTP status. Response bodies are never echoed, and any occurrence of the secret key in the metadata is redacted.

Match it with errors.As:

var apiErr *fingerly.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
	// ...
}
Example

Decide on the server's verdict, never on a score the browser supplied.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))

	decide := func(ctx context.Context, orderID, requestID string) (string, error) {
		event, err := client.Events.Get(ctx, requestID)
		var apiErr *fingerly.APIError
		switch {
		case errors.Is(err, fingerly.ErrEventPending):
			// Not decided yet, not fraud: retry after apiErr.RetryAfter
			// seconds or verify asynchronously.
			return "pending", nil
		case errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound:
			return "refuse", nil
		case err != nil:
			return "", err
		}

		if event.Tag != "checkout:"+orderID || time.Since(event.OccurredAt) > 2*time.Minute {
			return "refuse", nil
		}
		// An unscored event is not a clean one; treat it as unknown.
		if !event.Scored() {
			return "challenge", nil
		}
		switch event.SuspectLevel {
		case "high":
			return "review", nil
		case "medium":
			return "challenge", nil
		}
		return "allow", nil
	}

	verdict, err := decide(context.Background(), "8412", "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(verdict)
}

func (*APIError) Error

func (e *APIError) Error() string

Error implements error. It reports the status only, never the body.

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is reports whether this error matches a sentinel, so a caller can write errors.Is(err, fingerly.ErrEventNotFound) or errors.Is(err, fingerly.ErrEventPending) without unwrapping the status.

type Client

type Client struct {
	// Events reads stored events under this client's secret key.
	Events *EventsService
	// contains filtered or unexported fields
}

Client reads events for one organization and environment.

Build one with New and reuse it: it holds a pooled HTTP connection and is safe for concurrent use.

func New

func New(secretKey string, opts ...Option) *Client

New builds a client for a server-only secret key, fly_sk_{environment}_…, whose prefix selects the environment read.

New never returns an error, so it composes in a package-level var. An invalid key or option is reported by the first call made on the client, before any request is sent.

Example

Build one client and reuse it: it holds a pooled connection and is safe for concurrent use.

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))

	event, err := client.Events.Get(context.Background(), "01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(event.VisitorID, event.SuspectLevel)
}

func (Client) Format

func (c Client) Format(f fmt.State, _ rune)

Format keeps the secret key out of every fmt verb. Without it, fmt prints the struct's unexported fields, key included, for %v, %+v, %#v and %s.

type ConnectionError

type ConnectionError struct {
	// Timeout reports whether a deadline passed.
	Timeout bool
	// contains filtered or unexported fields
}

ConnectionError reports a read that got no complete answer from the API: the connection was refused, reset or timed out, or the body broke off. Its message never carries the secret key.

errors.Is(err, fingerly.ErrTimeout) is true when Timeout is, and the cause stays reachable, so errors.Is(err, context.Canceled) still works.

func (*ConnectionError) Error

func (e *ConnectionError) Error() string

Error implements error.

func (*ConnectionError) Is

func (e *ConnectionError) Is(target error) bool

Is matches ErrTimeout for a timed-out read.

func (*ConnectionError) Unwrap

func (e *ConnectionError) Unwrap() error

Unwrap returns the cause.

type Event

type Event struct {
	RequestID  string    `json:"request_id"`
	VisitorID  string    `json:"visitor_id"`
	OccurredAt time.Time `json:"occurred_at"`
	// State is "enriched", or "unavailable" when the network lookup could not run.
	State string `json:"state"`

	Environment string `json:"environment"`
	// Platform is the scoring profile applied; SDKPlatform is what the SDK
	// called itself. They differ: react-native-ios reports itself as such and
	// is scored as ios.
	Platform    string `json:"platform"`
	SDKPlatform string `json:"sdk_platform"`
	SDKVersion  string `json:"sdk_version"`

	Origin  string `json:"origin,omitempty"`
	URLHost string `json:"url_host,omitempty"`
	URLPath string `json:"url_path,omitempty"`
	// Tag is your own reference, as the client sent it.
	Tag string `json:"tag,omitempty"`

	IPAddress   string `json:"ip_address"`
	CountryCode string `json:"country_code,omitempty"`
	CountryName string `json:"country_name,omitempty"`
	ASN         string `json:"asn,omitempty"`
	ASNName     string `json:"asn_name,omitempty"`
	// AnonymityNetwork is "tor", "vpn", "proxy" or "hosting". Empty on an
	// ordinary consumer connection, which is most of them.
	AnonymityNetwork string `json:"anonymity_network,omitempty"`

	Agent        string `json:"agent,omitempty"`
	AgentVersion string `json:"agent_version,omitempty"`
	OS           string `json:"os,omitempty"`
	DeviceKind   string `json:"device_kind,omitempty"`
	VisitorKind  string `json:"visitor_kind,omitempty"`

	// SuspectScore is nil when the request was not scored. Never read nil as 0.
	SuspectScore *int `json:"suspect_score"`
	// SuspectLevel is "low", "medium" or "high". Empty when not scored.
	SuspectLevel string `json:"suspect_level,omitempty"`

	// Triggers is never nil from the API: an event about which nothing fired
	// carries an empty list.
	Triggers []EventTrigger `json:"triggers"`

	// HasDocument says whether the full submission was archived.
	HasDocument bool `json:"has_document"`

	// Document is the archived submission with the threshold and weights that
	// scored it. Present when HasDocument is true and the archive could be
	// read. Its contents are deliberately opaque, not a public contract.
	Document json.RawMessage `json:"document,omitempty"`
	// DeferredSignals is the deferred report attached to this request, when one
	// arrived. Also opaque.
	DeferredSignals json.RawMessage `json:"deferred_signals,omitempty"`

	// Raw is the response body exactly as it arrived, so a field the API adds
	// after this SDK was released is still reachable without an upgrade.
	Raw json.RawMessage `json:"-"`
}

Event is a stored identification event.

SuspectScore is a pointer because nil means unscored, which must not be read as zero: an ordinary visitor about whom nothing fired scores 0, while a submission that was never enriched has no score at all.

func (*Event) Score

func (e *Event) Score() (int, bool)

Score returns the suspect score and whether the event was scored at all.

score, ok := event.Score()
if !ok { /* unscored, decide on something else */ }

func (*Event) Scored

func (e *Event) Scored() bool

Scored reports whether the API assigned this event a suspect score.

Prefer it to a nil check: it states the question being asked.

type EventListParams

type EventListParams struct {
	// From and To bound the window. The default is the last 24 hours; it may
	// be at most 30 days wide and start at most 30 days ago.
	From time.Time
	To   time.Time
	// Page is one-based. Limit is capped at 200 by the server.
	Page  int
	Limit int
	// Visitor restricts the listing to one exact visitor id.
	Visitor string
	// Level restricts it to "low", "medium" or "high".
	Level string
}

EventListParams filters a listing. A zero field is not sent, so the server applies its own default.

type EventPage

type EventPage struct {
	Rows     []Event `json:"rows"`
	Page     int     `json:"page"`
	PageSize int     `json:"page_size"`

	// Raw is the response body exactly as it arrived.
	Raw json.RawMessage `json:"-"`
}

EventPage is one page of events, newest first.

type EventTrigger

type EventTrigger struct {
	// Signal is the signal group, such as "vpn" or "bot".
	Signal string `json:"signal"`
	// Weight is the group's combined weight in this score.
	Weight int `json:"weight"`
	// Confidence is "low", "medium" or "high".
	Confidence string `json:"confidence,omitempty"`
}

EventTrigger is one signal group that contributed to an event's score.

type EventsService

type EventsService struct {
	// contains filtered or unexported fields
}

EventsService reads events stored for the organization and environment of the client's secret key. Reach it as client.Events.

func (EventsService) Format

func (s EventsService) Format(f fmt.State, _ rune)

Format keeps the client's key out of every fmt verb; the service holds the client in an unexported field, which fmt prints without its methods.

func (*EventsService) Get

func (s *EventsService) Get(ctx context.Context, requestID string) (*Event, error)

Get fetches one event by request id.

An error matching ErrEventPending means the request id was accepted but the event is not recorded yet; APIError.RetryAfter says when to try again. One matching ErrEventNotFound means it was never issued or is past retention. Neither is retried.

An empty request id, "." or ".." returns ErrInvalidRequestID without sending anything.

func (*EventsService) List

func (s *EventsService) List(ctx context.Context, params *EventListParams) (*EventPage, error)

List returns events within the retention window, newest first.

A nil params, or a zero field within it, leaves that filter unset so the server applies its own default.

Example

A zero field is not sent, so the server applies its own default.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	client := fingerly.New(os.Getenv("FINGERLY_SECRET_KEY"))

	page, err := client.Events.List(context.Background(), &fingerly.EventListParams{
		Level: "high",
		Limit: 50,
		From:  time.Now().Add(-24 * time.Hour),
	})
	if err != nil {
		log.Fatal(err)
	}
	for _, event := range page.Rows {
		fmt.Println(event.RequestID, event.IPAddress)
	}
}

type Option

type Option func(*clientConfig)

Option configures a Client. Pass options to New.

func WithAPIVersion

func WithAPIVersion(version string) Option

WithAPIVersion selects a route version, such as "v2". The default is "v1", and the server must support whichever is chosen.

func WithEndpoint

func WithEndpoint(endpoint string) Option

WithEndpoint overrides the default API origin, for a local or self-hosted API. The key still decides which kind of key is accepted.

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient supplies the HTTP client, for a custom transport or for tests.

The client is copied and its CheckRedirect replaced, so a redirect can never replay the secret key to another host. Its Transport is used as given; a nil Transport means the SDK's shared connection pool rather than http.DefaultTransport, which keeps too few idle connections for a busy server.

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the deadline for each request, covering the response body. The default is 10 seconds. A deadline already on the caller's context still applies, whichever is sooner.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy serves the browser SDK from a path on your own domain, so content blockers cannot refuse its calls as third-party.

It implements http.Handler, so every router built on net/http takes it directly:

proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
	ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"),
	Path:     "/k7x2qm4a",
	ClientIP: func(r *http.Request) string { return r.Header.Get("X-Real-IP") },
})
mux.Handle("/k7x2qm4a", proxy)

The browser SDK posts both of its calls to that one path, and the proxy forwards each to Fingerly's one ingest URL, never anywhere else and never to event reads: reading events needs a secret key and must stay unreachable from a browser. Customer cookies and authorization headers are never passed on, and nothing relayed back names Fingerly.

func NewProxy

func NewProxy(options ProxyOptions) (*Proxy, error)

NewProxy validates every option and builds the handler.

Unlike New, this reports configuration errors immediately: a proxy is wired up once at start-up, where an error is natural to handle.

Example

The proxy is an http.Handler, so every router takes it directly.

package main

import (
	"log"
	"net"
	"net/http"
	"os"

	fingerly "github.com/fingerly-io/fingerly-go"
)

func main() {
	proxy, err := fingerly.NewProxy(fingerly.ProxyOptions{
		ProxyKey: os.Getenv("FINGERLY_PROXY_KEY"),
		Path:     "/k7x2qm4a",
		ClientIP: func(r *http.Request) string {
			// The header your load balancer or CDN sets. Trust it only when
			// traffic can reach this server through that hop alone; never
			// trust the left-most X-Forwarded-For value, which the visitor
			// writes.
			if address := r.Header.Get("X-Real-IP"); address != "" {
				return address
			}
			host, _, _ := net.SplitHostPort(r.RemoteAddr)
			return host
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.Handle("/k7x2qm4a", proxy)
	log.Fatal(http.ListenAndServe(":8080", mux))
}

func (Proxy) Format

func (p Proxy) Format(f fmt.State, _ rune)

Format keeps the proxy key out of every fmt verb. A value receiver, so that printing a dereferenced Proxy is covered too.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type ProxyError

type ProxyError struct {
	// Status is what the browser was answered: 502, 504 or 500.
	Status int
	// Err is the underlying cause.
	Err error
}

ProxyError is an upstream failure the proxy answered for. It never carries a key or a response body.

func (*ProxyError) Error

func (e *ProxyError) Error() string

Error implements error.

func (*ProxyError) Unwrap

func (e *ProxyError) Unwrap() error

Unwrap returns the underlying cause.

type ProxyOptions

type ProxyOptions struct {
	// ProxyKey is a write-only credential, fly_px_…, held only on your server.
	ProxyKey string
	// SecretKey is the deprecated alternative to ProxyKey. Secret read keys
	// will be refused in the next major version; do not pass both.
	SecretKey string

	// ClientIP resolves the visitor's address, which must be exactly one IPv4
	// or IPv6 literal. Read it from a header your own infrastructure sets,
	// such as your load balancer's or CDN's, and fall back to the TCP peer in
	// r.RemoteAddr. Never use the left-most X-Forwarded-For value: the visitor
	// writes it. Anything that is not one address is a configuration error:
	// the proxy answers 500 without calling upstream and reports it once.
	ClientIP func(*http.Request) string

	// Path is the path the dashboard issued for this proxy, such as
	// "/k7x2qm4a", matched exactly: any other path gets 404. Leave it empty
	// when the route already sends only that path to the handler, and behind
	// http.StripPrefix, which changes the path the handler sees.
	Path string
	// APIVersion selects the upstream ingest URL. Defaults to "v1".
	APIVersion string
	// Upstream overrides the API origin implied by the key.
	Upstream string
	// MaxBodyBytes refuses larger request bodies with 413. Defaults to 1 MiB.
	MaxBodyBytes int64
	// Timeout bounds the upstream call. Defaults to 5 seconds.
	Timeout time.Duration
	// OnError is called once for every request the proxy answered with a 5xx
	// of its own: 504 when the upstream deadline passed, 502 for any other
	// upstream failure, 500 for a ClientIP that returned no valid address.
	// Leave it nil to log one line per failure through slog.Default(). A
	// visitor who disconnects is not a failure and is not reported.
	OnError func(*http.Request, *ProxyError)
	// Transport supplies the upstream round tripper, for tests or a custom
	// HTTP stack. Left nil, the SDK's shared connection pool is used, sized
	// for hundreds of requests in flight. Redirects are never followed either
	// way.
	Transport http.RoundTripper
}

ProxyOptions configures a Proxy. ProxyKey and ClientIP are required.

func (ProxyOptions) Format

func (o ProxyOptions) Format(f fmt.State, _ rune)

Format keeps the keys out of every fmt verb, so logging the options a proxy was built with is safe.

func (ProxyOptions) GobEncode

func (o ProxyOptions) GobEncode() ([]byte, error)

GobEncode refuses to serialise the options: gob skips the callbacks but would copy the keys in the clear.

func (ProxyOptions) MarshalJSON

func (o ProxyOptions) MarshalJSON() ([]byte, error)

MarshalJSON keeps the keys out of JSON, and leaves out the callbacks, which cannot be encoded.

type WebhookEvent

type WebhookEvent struct {
	// ID identifies this delivery. Persist it to deduplicate: a webhook may
	// arrive more than once.
	ID string `json:"id"`
	// Type is the event name, such as "identification.completed".
	Type string `json:"type"`
	// Version is the envelope version.
	Version int `json:"version"`
	// OrganizationID is the organization the event belongs to.
	OrganizationID string `json:"organization_id"`
	// Environment is the deployment the event came from.
	Environment string `json:"environment"`
	// CreatedAt is when Fingerly recorded the event.
	CreatedAt time.Time `json:"created_at"`
	// Data is the event-specific payload.
	Data json.RawMessage `json:"data"`
}

WebhookEvent is the envelope every webhook delivery shares.

Data holds the event-specific payload, left raw so a new event type needs no SDK upgrade. Unmarshal it into the shape the event documents.

type WebhookOption

type WebhookOption func(*webhookConfig)

WebhookOption configures VerifyWebhook.

func WithWebhookTime

func WithWebhookTime(now time.Time) WebhookOption

WithWebhookTime fixes the current time. Override it only in tests.

func WithWebhookTolerance

func WithWebhookTolerance(tolerance time.Duration) WebhookOption

WithWebhookTolerance sets the replay window. The default is five minutes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL