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_atbecomes a whole Unix-second timestamp- event JSON is serialized exactly once
- a
2xxresult marks the outbox row delivered 429,5xx, and network timeouts retry400moves 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.