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
5 changes: 5 additions & 0 deletions .sampo/changesets/sullen-princess-ukko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
hex/posthog: minor
---

This is *technically* a breaking change because we're now always sending data gzip compressed and people might not want that, but this will not break anyone's code so we'll release it as a minor knowing that it's an improvement. It's always been possible to swap the client off, but we weren't documenting how to do that exactly - this is now solved too.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,44 @@ You can always disable it by setting `enable_error_tracking` to false:
config :posthog, enable_error_tracking: false
```

## Custom HTTP Client

The SDK uses [Req](https://hexdocs.pm/req) under the hood with gzip compression and
transient retry enabled by default. You can swap in your own HTTP client module to
change any of this behaviour — disable compression, add custom headers, attach
telemetry, or use a completely different HTTP library.

Set the `api_client_module` config option to a module that implements the
`PostHog.API.Client` behaviour:

```elixir
config :posthog, api_client_module: MyApp.PostHogClient
```

The simplest approach is to wrap the default client and override only what you need:

```elixir
defmodule MyApp.PostHogClient do
@behaviour PostHog.API.Client

@impl true
def client(api_key, api_host) do
default = PostHog.API.Client.client(api_key, api_host)

# Disable gzip compression
custom = Req.merge(default.client, compress_body: false)

%{default | client: custom}
end

@impl true
defdelegate request(client, method, url, opts), to: PostHog.API.Client
end
```

See `PostHog.API.Client` docs for more examples, including adding custom headers
and using a different HTTP library entirely.

## Multiple PostHog Projects

If your app works with multiple PostHog projects, PostHog can accommodate you. For
Expand Down Expand Up @@ -276,6 +314,7 @@ sampo add
```

Follow the prompts to specify:

- The type of change (`patch`, `minor`, or `major`)
- A description of the change for the changelog

Expand All @@ -293,6 +332,7 @@ the [PostHog SDK releases process](https://posthog.com/handbook/engineering/sdks
3. **Merge the PR** into `master`

Once merged, the release workflow will automatically:

- Consume all pending changesets
- Update the version in `mix.exs`
- Update `CHANGELOG.md` with the new entries
Expand Down
86 changes: 82 additions & 4 deletions lib/posthog/api/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ defmodule PostHog.API.Client do
@moduledoc """
Behaviour and the default implementation of a PostHog API client. Uses `Req`.

Users are unlikely to interact with this module directly, but here's an
example just in case:
The default client sends request bodies gzip-compressed and retries on transient
failures. If you need different behaviour — for example, to disable compression,
add custom headers, attach telemetry, or use a different HTTP library — you can
implement this behaviour in your own module and configure it via `api_client_module`.

## Example
## Using the default client directly

> client = PostHog.API.Client.client("phc_abcdedfgh", "https://us.i.posthog.com")
%PostHog.API.Client{
Expand All @@ -15,6 +17,82 @@ defmodule PostHog.API.Client do

> client.module.request(client.client, :post, "/flags", json: %{distinct_id: "user123"}, params: %{v: 2, config: true})
{:ok, %Req.Response{status: 200, body: %{...}}}

## Writing a custom client

Implement the `client/2` and `request/4` callbacks, then set the config:

config :posthog, api_client_module: MyApp.PostHogClient

### Wrapping the default client

The easiest approach is to delegate to the default client and override only what
you need. For example, to disable gzip compression:

defmodule MyApp.PostHogClient do
@behaviour PostHog.API.Client

@impl true
def client(api_key, api_host) do
default = PostHog.API.Client.client(api_key, api_host)
# Remove the compress_body step added by the default client
custom = Req.merge(default.client, compress_body: false)
%{default | client: custom}
end

@impl true
defdelegate request(client, method, url, opts), to: PostHog.API.Client
end

### Adding custom request headers

defmodule MyApp.PostHogClient do
@behaviour PostHog.API.Client

@impl true
def client(api_key, api_host) do
default = PostHog.API.Client.client(api_key, api_host)
custom = Req.merge(default.client, headers: [{"x-custom-header", "value"}])
%{default | client: custom}
end

@impl true
defdelegate request(client, method, url, opts), to: PostHog.API.Client
end

### Using a different HTTP library

You can skip `Req` entirely and use any HTTP client. The `client` term you return
is opaque — it's passed back to your `request/4` callback as-is.

NOTE: The code below is not guaranteed to be correct or complete — it's just illustrative of the general approach.

defmodule MyApp.FinchPostHogClient do
@behaviour PostHog.API.Client

@impl true
def client(api_key, api_host) do
%PostHog.API.Client{
client: %{api_key: api_key, api_host: api_host},
module: __MODULE__
}
end

@impl true
def request(client, method, url, opts) do
body = opts[:json] |> Map.put_new(:api_key, client.api_key) |> Jason.encode!()

Finch.build(method, client.api_host <> url, [{"content-type", "application/json"}], body)
|> Finch.request(MyApp.Finch)
|> case do
{:ok, %Finch.Response{status: status, body: body}} ->
{:ok, %{status: status, body: Jason.decode!(body)}}

{:error, exception} ->
{:error, exception}
end
end
end
"""
@behaviour __MODULE__

Expand Down Expand Up @@ -48,7 +126,7 @@ defmodule PostHog.API.Client do
@impl __MODULE__
def client(api_key, api_host) do
client =
Req.new(base_url: api_host, retry: :transient)
Req.new(base_url: api_host, retry: :transient, compress_body: true)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some SDKs provide a client config for that eg https://github.com/PostHog/posthog-js/blob/a3cc497d977298637155a2c15030f3fcfe1e613f/packages/types/src/posthog-config.ts#L1228-L1234
also remote config returns supportedCompression which then we know if the server supports gzip or not
not a blocker, as this is already the default behaviour for many SDKs, and PH Cloud supports gzip.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing that to the client is a bit complicated and very not-Elixir. The best way to do this in Elixir really is to let people override the client - like we explained in the README.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what i meant is the posthog config client, something next to api_key and api_host, where compress_body is optiona and defaults to true
its a much better UX to expose a compress_body config rather than telling the user to replace the whole http client.
Simpleness wins here, not a blocker.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can absolutely do that in the future if we wanna reduce the number of times people need to redefine the client, but you can also look at the docs above and see that "replacing the client" is very easy in Elixir.

Also, I'm pretty sure Elixir-heavy companies will find replacing the client to be a much sounder approach than just throwing yet another new config - this also simplifies the whole implementation, there's less things for people to think about if they don't have the 200 options we have in posthog-js, for example.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

happy to disagree with what would be easier, a boolean vs replacing the client (I did see the docs).
obv 200 options bloats and isn't great either.
alright, PR is approved anyway so it was just a suggestion

|> Req.Request.put_private(:api_key, api_key)

%__MODULE__{client: client, module: __MODULE__}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ defmodule SdkComplianceAdapter.TrackedClient do
defdelegate request(client, method, url, opts), to: PostHog.API.Client

def track({request, response}) do
req_body =
req_body =
request.body
|> to_string()
|> decompress(request)
|> JSON.decode!()

uuid_list = extract_uuids(req_body)
event_count = count_events(req_body)

Expand All @@ -40,6 +41,13 @@ defmodule SdkComplianceAdapter.TrackedClient do
{request, exception}
end

defp decompress(body, request) do
case Req.Request.get_header(request, "content-encoding") do
["gzip"] -> :zlib.gunzip(body)
_ -> body
end
end

defp extract_uuids(request) do
request
|> get_in([Access.key("batch", []), Access.all(), "uuid"])
Expand Down