DocsRecipes

Recipes

Rails reference implementation

3 min readReviewed July 2026

This reference keeps GraphJSON behind your Rails application boundary. Controllers record business facts; a job or outbox delivers them; one client owns serialization, timeouts, and error handling.

Use direct delivery only for disposable telemetry. If losing an event would change a customer-visible or financial metric, use the outbox path below.

Configure secrets on the server#

Store the API key in encrypted credentials or your deployment secret manager:

# config/credentials.yml.enc
graphjson:
  api_key: ...
  collection: product_events

Expose configuration through a small object so missing production values fail during boot:

# config/initializers/graphjson.rb
Rails.application.config.x.graphjson.api_key =
  Rails.application.credentials.dig(:graphjson, :api_key)
Rails.application.config.x.graphjson.collection =
  Rails.application.credentials.dig(:graphjson, :collection) ||
  "product_events"

if Rails.env.production? &&
    Rails.application.config.x.graphjson.api_key.blank?
  raise "GraphJSON API key is not configured"
end

Never put this key in an asset, view, data-* attribute, or variable with a public-client prefix.

Keep delivery in one client#

The GraphJSON logging envelope contains a serialized JSON string inside a JSON request body. JSON.generate is called once for the event and once for the envelope:

# app/services/graphjson_client.rb
require "json"
require "net/http"

class GraphjsonClient
  ENDPOINT = URI("https://api.graphjson.com/api/log")

  Result = Struct.new(:status, :body, keyword_init: true) do
    def success?
      status.between?(200, 299)
    end

    def retryable?
      status == 429 || status >= 500
    end
  end

  def initialize(
    api_key: Rails.configuration.x.graphjson.api_key,
    collection: Rails.configuration.x.graphjson.collection
  )
    @api_key = api_key
    @collection = collection
  end

  def log(event:, occurred_at:)
    request = Net::HTTP::Post.new(
      ENDPOINT,
      "Content-Type" => "application/json"
    )
    request.body = JSON.generate(
      api_key: @api_key,
      collection: @collection,
      timestamp: occurred_at.to_i,
      json: JSON.generate(event)
    )

    response = Net::HTTP.start(
      ENDPOINT.hostname,
      ENDPOINT.port,
      use_ssl: true,
      open_timeout: 3,
      read_timeout: 8
    ) { |http| http.request(request) }

    Result.new(status: response.code.to_i, body: response.body.to_s)
  end
end

Do not include the request body in an exception or log entry: it contains the API key and event data.

Define the event at the business boundary#

Create the event from server-owned records, not from an arbitrary browser payload:

def report_exported_event(
  report:,
  actor:,
  account:,
  format:,
  event_id: SecureRandom.uuid
)
  {
    event: "report_exported",
    event_id: event_id,
    schema_version: 1,
    report_id: report.id.to_s,
    user_id: actor.id.to_s,
    account_id: account.id.to_s,
    format: format
  }
end

If the browser initiates the action, accept only the fields it legitimately controls. Derive user_id and account_id from the authenticated session, and validate the selected account before building the event.

Enqueue only committed facts#

A job scheduled before a database transaction commits can observe a row that later rolls back. For ordinary important events, enqueue after commit:

class ReportExport < ApplicationRecord
  after_create_commit :enqueue_analytics

  private

  def enqueue_analytics
    DeliverAnalyticsEventJob.perform_later(
      {
        event: "report_exported",
        event_id: analytics_event_id,
        schema_version: 1,
        report_id: report_id.to_s,
        user_id: user_id.to_s,
        account_id: account_id.to_s,
        format: format
      },
      created_at.iso8601(6)
    )
  end
end

Persist analytics_event_id when the business record is created. A retry must reuse it.

An after_commit callback closes the rollback race, but it does not make enqueueing atomic: the process can stop after commit and before the queue accepts the job. Use an outbox when that gap is unacceptable.

Use an outbox for durable delivery#

Create the business record and analytics intent in the same transaction:

class AnalyticsOutbox < ApplicationRecord
  enum state: {
    pending: "pending",
    delivered: "delivered",
    rejected: "rejected"
  }

  validates :event_id, presence: true, uniqueness: true
  validates :occurred_at, presence: true
end
ApplicationRecord.transaction do
  export = ReportExport.create!(export_attributes)
  event_id = SecureRandom.uuid

  AnalyticsOutbox.create!(
    event_id: event_id,
    occurred_at: export.created_at,
    payload: report_exported_event(
      report: export.report,
      actor: current_user,
      account: current_account,
      format: export.format,
      event_id: event_id
    ),
    state: "pending"
  )
end

A scheduled dispatcher claims pending rows using your database’s locking strategy and enqueues their IDs. The delivery job loads the row, sends it, and marks it delivered:

class DeliverAnalyticsOutboxJob < ApplicationJob
  queue_as :analytics

  retry_on Net::OpenTimeout, Net::ReadTimeout,
    wait: :polynomially_longer, attempts: 8

  def perform(outbox_id)
    row = AnalyticsOutbox.pending.find(outbox_id)
    result = GraphjsonClient.new.log(
      event: row.payload,
      occurred_at: row.occurred_at
    )

    if result.success?
      row.update!(state: "delivered", delivered_at: Time.current)
    elsif result.retryable?
      raise "retryable GraphJSON response: #{result.status}"
    else
      row.update!(
        state: "rejected",
        last_status: result.status,
        last_error: result.body.first(300)
      )
    end
  end
end

Store only a redacted error excerpt. Do not retry an unchanged validation failure forever. If a timeout leaves acceptance uncertain, retry with the same event_id and deduplicate that identifier in analytical queries.

Operate the pipeline#

Monitor queue age as well as failure count. A worker that is succeeding slowly can still make dashboards stale.

Useful signals include:

  • oldest pending outbox age
  • pending, delivered, rejected, and retried counts
  • response status class
  • delivery duration
  • event name and schema version
  • collection and deployment environment

Do not log the API key or entire payload. Configure alerts for sustained pending age, a burst of 400 responses, or repeated 429 and 5xx responses.

Test the contract and failure modes#

At minimum, test that:

  • a rolled-back transaction creates no outbox row
  • an event contains the authenticated user and authorized account
  • occurred_at becomes a whole Unix-second timestamp
  • event JSON is serialized exactly once
  • a 2xx result marks the outbox row delivered
  • 429, 5xx, and network timeouts retry
  • 400 moves to review instead of retrying forever
  • retries preserve event_id
  • logs and exceptions do not contain the API key

Use a stub HTTP server or request adapter in unit tests. Keep one staging smoke test that sends a known event into a staging collection and verifies it in Samples.

Production checklist#

  • The API key exists only in server-side credentials.
  • Event constructors accept domain objects, not arbitrary request bodies.
  • User and account identifiers come from authorized server state.
  • Important events use an outbox or another durable queue boundary.
  • Event IDs are stable across retries.
  • Network timeouts and status-aware retry rules are explicit.
  • Permanent validation failures move to review.
  • Queue age, rejection rate, and delivery latency are monitored.
  • Payloads and secrets are redacted from logs.
  • Contract and failure-mode tests run in CI.

See Executable event contracts for schema validation and Reliable event delivery for the delivery decision tree.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support