Skip to content

Commit b6e57d2

Browse files
authored
Last9.io adaptor (#2953)
* feat: Last9 backend Adaptor * fix: Last9 test mocking
1 parent a5d0b43 commit b6e57d2

File tree

8 files changed

+314
-13
lines changed

8 files changed

+314
-13
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
sidebar_position: 11
3+
---
4+
5+
# Last9.io
6+
7+
The Last9.io backend is **ingest-only**, and sends logs to [Last9](https://last9.io) via OTLP/HTTP.
8+
9+
## Behaviour and configurations
10+
11+
### Configuration
12+
13+
The backend can be configured with the following options:
14+
15+
- `:region` (string, required) - The region for the endpoint. Can be either `US-WEST-1` or `AP-SOUTH-1`.
16+
- `:username` (string, required) - The username for authentication.
17+
- `:password` (string, required) - The password for authentication.
18+
19+
You can obtain the username and password from the [Last9 OTel integration panel](https://app.last9.io/integrations?integration=OpenTelemetry).
20+
21+
### Implementation Details
22+
23+
- The adaptor sends logs to a region-specific endpoint (e.g., `https://otlp.last9.io/v1/logs`).
24+
- It uses OTLP over HTTP with Protobuf encoding.
25+
- Authentication is handled via HTTP Basic Auth using the provided `:username` and `:password`.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
defmodule Logflare.Backends.Adaptor.Last9Adaptor do
2+
@moduledoc """
3+
Adaptor sending logs to [Last9](https://app.last9.io) via OTLP/HTTP
4+
This adaptor is **ingest-only**
5+
6+
## Configuration
7+
- `:region` - Region determining endpoint address
8+
- `:username`, `:password` - Auth data obtained via [Last9 OTel integration panel](https://app.last9.io/integrations?integration=OpenTelemetry)
9+
"""
10+
11+
alias Logflare.Backends.Adaptor
12+
alias Logflare.Backends.Adaptor.HttpBased
13+
alias Logflare.Backends.Adaptor.OtlpAdaptor
14+
alias Logflare.Backends.Adaptor.OtlpAdaptor.ProtobufFormatter
15+
alias Logflare.Backends.Backend
16+
17+
@behaviour Adaptor
18+
@behaviour HttpBased.Client
19+
20+
@region_mapping %{
21+
"US-WEST-1" => "https://otlp.last9.io",
22+
"AP-SOUTH-1" => "https://otlp-aps1.last9.io"
23+
}
24+
@regions Map.keys(@region_mapping)
25+
26+
def regions(), do: @regions
27+
def endpoint_per_region(region), do: @region_mapping[region]
28+
29+
def child_spec(init_arg) do
30+
%{
31+
id: __MODULE__,
32+
start: {__MODULE__, :start_link, [init_arg]}
33+
}
34+
end
35+
36+
@impl Adaptor
37+
def start_link({source, backend}) do
38+
HttpBased.Pipeline.start_link(source, backend, __MODULE__)
39+
end
40+
41+
@impl Adaptor
42+
def cast_config(params) do
43+
types = %{
44+
region: :string,
45+
username: :string,
46+
password: :string
47+
}
48+
49+
{%{}, types}
50+
|> Ecto.Changeset.cast(params, Map.keys(types))
51+
end
52+
53+
@impl Adaptor
54+
def validate_config(changeset) do
55+
changeset
56+
|> Ecto.Changeset.validate_required([:region, :username, :password])
57+
|> Ecto.Changeset.validate_inclusion(:region, @regions)
58+
end
59+
60+
@impl Adaptor
61+
def redact_config(config) do
62+
config
63+
|> Map.put(:password, "REDACTED")
64+
end
65+
66+
@impl Adaptor
67+
def test_connection(args) do
68+
OtlpAdaptor.Common.test_connection(__MODULE__, args)
69+
end
70+
71+
@impl HttpBased.Client
72+
def client_opts(%Backend{config: config}) do
73+
[
74+
url: Path.join(@region_mapping[config.region], "/v1/logs"),
75+
formatter: ProtobufFormatter,
76+
basic_auth: [username: config.username, password: config.password],
77+
gzip: true,
78+
json: false
79+
]
80+
end
81+
end

lib/logflare/backends/adaptor/otlp_adaptor.ex

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,8 @@ defmodule Logflare.Backends.Adaptor.OtlpAdaptor do
7878
end
7979

8080
@impl Adaptor
81-
def test_connection({_source, backend}) do
82-
test_connection(backend)
83-
end
84-
85-
def test_connection(%Backend{} = backend) do
86-
case HttpBased.Client.send_events(__MODULE__, [], backend) do
87-
{:ok, %Tesla.Env{status: 200, body: %{partial_success: nil}}} -> :ok
88-
{:ok, %Tesla.Env{status: 200, body: %{partial_success: %{error_message: ""}}}} -> :ok
89-
{:ok, env} -> {:error, env}
90-
{:error, _reason} = err -> err
91-
end
81+
def test_connection(args) do
82+
__MODULE__.Common.test_connection(__MODULE__, args)
9283
end
9384

9485
@impl HttpBased.Client
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Logflare.Backends.Adaptor.OtlpAdaptor.Common do
2+
alias Logflare.Backends.Backend
3+
alias Logflare.Backends.Adaptor.HttpBased
4+
alias Logflare.Sources.Source
5+
6+
@doc """
7+
A code for testing connection meant to be shared by all OTLP based adaptors.
8+
9+
Sends an empty list of events, expecting success response.
10+
"""
11+
@spec test_connection(module(), {Source.t(), Backend.t()} | Backend.t()) ::
12+
:ok | {:error, term()}
13+
def test_connection(client_module, {_source, backend}) do
14+
test_connection(client_module, backend)
15+
end
16+
17+
def test_connection(client_module, %Backend{} = backend) do
18+
case HttpBased.Client.send_events(client_module, [], backend) do
19+
{:ok, %Tesla.Env{status: 200, body: %{partial_success: nil}}} -> :ok
20+
{:ok, %Tesla.Env{status: 200, body: %{partial_success: %{error_message: ""}}}} -> :ok
21+
{:ok, env} -> {:error, env}
22+
{:error, _reason} = err -> err
23+
end
24+
end
25+
end

lib/logflare/backends/backend.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ defmodule Logflare.Backends.Backend do
2525
incidentio: Adaptor.IncidentioAdaptor,
2626
s3: Adaptor.S3Adaptor,
2727
axiom: Adaptor.AxiomAdaptor,
28-
otlp: Adaptor.OtlpAdaptor
28+
otlp: Adaptor.OtlpAdaptor,
29+
last9: Adaptor.Last9Adaptor
2930
}
3031

3132
typed_schema "backends" do

lib/logflare_web/live/backends/backends_live.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,8 @@ defmodule LogflareWeb.BackendsLive do
298298
{"Incident.io", :incidentio},
299299
{"S3", :s3},
300300
{"Axiom", :axiom},
301-
{"OTLP", :otlp}
301+
{"OTLP", :otlp},
302+
{"Last9", :last9}
302303
])
303304
end
304305

lib/logflare_web/live/backends/components/backend_form.heex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,26 @@
358358
Transport protocol used for OTLP
359359
</small>
360360
</div>
361+
<% "last9" -> %>
362+
<div class="form-group">
363+
{label(f_config, :username, "Otel integration Username")}
364+
{text_input(f_config, :username, class: "form-control")}
365+
{label(f_config, :password, "Otel integration Password")}
366+
{text_input(f_config, :password, class: "form-control", type: "password")}
367+
<small class="form-text text-muted">
368+
Auth data obtained via <a href="https://app.last9.io/integrations?integration=OpenTelemetry">Last9 OTel integration panel</a>
369+
</small>
370+
</div>
371+
<div class="form-group">
372+
{label(f_config, :region, "Region")}
373+
{select(f_config, :region, Logflare.Backends.Adaptor.Last9Adaptor.regions(),
374+
class: "form-control form-control-margin",
375+
id: "last9-region"
376+
)}
377+
<small class="form-text text-muted">
378+
Region of your cluster
379+
</small>
380+
</div>
361381
<% _ -> %>
362382
<div>Select a Backend Type</div>
363383
<% end %>
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
defmodule Logflare.Backends.Adaptor.Last9AdaptorTest do
2+
use Logflare.DataCase, async: false
3+
4+
alias Logflare.Backends
5+
alias Logflare.Backends.Adaptor
6+
alias Logflare.Backends.Adaptor.HttpBased
7+
alias Logflare.Backends.AdaptorSupervisor
8+
alias Logflare.SystemMetrics.AllLogsLogged
9+
alias Opentelemetry.Proto.Collector.Logs.V1.ExportLogsServiceRequest
10+
alias Opentelemetry.Proto.Collector.Logs.V1.ExportLogsServiceResponse
11+
12+
@subject Adaptor.Last9Adaptor
13+
@tesla_adapter Tesla.Adapter.Finch
14+
15+
@valid_config %{
16+
region: "US-WEST-1",
17+
username: "testuser",
18+
password: "testpassword"
19+
}
20+
@valid_config_input Map.new(@valid_config, fn {k, v} -> {Atom.to_string(k), v} end)
21+
22+
defp backend_data(_ctx) do
23+
user = insert(:user)
24+
source = insert(:source, user: user)
25+
26+
backend =
27+
insert(:backend,
28+
type: :last9,
29+
sources: [source],
30+
config: @valid_config
31+
)
32+
33+
[backend: backend, source: source]
34+
end
35+
36+
setup do
37+
start_supervised!(AllLogsLogged)
38+
insert(:plan)
39+
:ok
40+
end
41+
42+
describe "config typecast and validation" do
43+
test "enforces required options" do
44+
changeset = Adaptor.cast_and_validate_config(@subject, %{})
45+
refute changeset.valid?
46+
assert errors_on(changeset).region == ["can't be blank"]
47+
assert errors_on(changeset).username == ["can't be blank"]
48+
assert errors_on(changeset).password == ["can't be blank"]
49+
end
50+
51+
test "validates region" do
52+
changeset =
53+
Adaptor.cast_and_validate_config(@subject, %{
54+
@valid_config_input
55+
| "region" => "invalid-region"
56+
})
57+
58+
refute changeset.valid?
59+
assert errors_on(changeset).region == ["is invalid"]
60+
end
61+
62+
test "accepts valid config" do
63+
changeset =
64+
Adaptor.cast_and_validate_config(@subject, @valid_config_input)
65+
66+
assert changeset.valid?
67+
end
68+
end
69+
70+
describe "test_connection/1" do
71+
setup :backend_data
72+
73+
test "succceeds on 200 response", ctx do
74+
response_body =
75+
%ExportLogsServiceResponse{partial_success: nil}
76+
|> Protobuf.encode()
77+
78+
mock_adapter(2, fn env ->
79+
assert env.method == :post
80+
assert env.url == "https://otlp.last9.io/v1/logs"
81+
assert Tesla.get_header(env, "authorization") == "Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk"
82+
83+
{:ok,
84+
%Tesla.Env{
85+
status: 200,
86+
body: response_body,
87+
headers: [{"content-type", "application/x-protobuf"}]
88+
}}
89+
end)
90+
91+
assert :ok = @subject.test_connection({ctx.source, ctx.backend})
92+
assert :ok = @subject.test_connection(ctx.backend)
93+
end
94+
95+
test "returns error on failure", ctx do
96+
error_responses = [
97+
{:ok, %Tesla.Env{status: 401, body: "forbidden"}},
98+
{:error, :nxdomain}
99+
]
100+
101+
for response <- error_responses do
102+
mock_adapter(fn _env -> response end)
103+
assert {:error, _reason} = @subject.test_connection(ctx.backend)
104+
end
105+
end
106+
end
107+
108+
describe "logs ingestion" do
109+
setup :backend_data
110+
111+
setup %{source: source, backend: backend} do
112+
start_supervised!({AdaptorSupervisor, {source, backend}})
113+
:timer.sleep(250)
114+
:ok
115+
end
116+
117+
test "sends logs via REST API", %{source: source} do
118+
this = self()
119+
ref = make_ref()
120+
121+
mock_adapter(fn env ->
122+
assert Tesla.build_url(env) == "https://otlp.last9.io/v1/logs"
123+
assert env.method == :post
124+
assert Tesla.get_header(env, "content-type") == "application/x-protobuf"
125+
assert Tesla.get_header(env, "authorization") == "Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk"
126+
127+
send(this, {ref, IO.iodata_to_binary(env.body)})
128+
{:ok, %Tesla.Env{status: 200, body: ""}}
129+
end)
130+
131+
log_events = build_list(3, :log_event, source: source)
132+
133+
assert {:ok, _} = Backends.ingest_logs(log_events, source)
134+
assert_receive {^ref, body}, 5000
135+
assert request = Protobuf.decode(body, ExportLogsServiceRequest)
136+
assert %{resource_logs: [%{scope_logs: [%{log_records: [_, _, _]}]}]} = request
137+
end
138+
end
139+
140+
describe "redact_config/1" do
141+
test "redacts username and password" do
142+
redacted_config = @subject.redact_config(@valid_config)
143+
assert redacted_config.password == "REDACTED"
144+
end
145+
end
146+
147+
defp mock_adapter(calls_num \\ 1, function) do
148+
stub(@tesla_adapter)
149+
150+
HttpBased.Client
151+
|> expect(:new, calls_num, fn opts ->
152+
HttpBased.Client
153+
|> Mimic.call_original(:new, [opts])
154+
|> Logflare.Tesla.MockAdapter.replace(function)
155+
end)
156+
end
157+
end

0 commit comments

Comments
 (0)