@hapic/loki
v2.1.0
Published
Typed HTTP client for Grafana Loki: push log streams, run LogQL queries and manage deletion requests.
Readme
Why @hapic/loki?
Talking to Loki's HTTP API with plain fetch means writing the same glue in every project: the { streams: [...] } push body, timestamps as nanosecond strings (JSON.stringify throws on a bigint, and Date.now() counts milliseconds), URL-encoded LogQL, a check of response.ok after every call and, once Loki runs as separate components, one base URL per component.
| Concern | Without it (fetch) | With @hapic/loki |
|---|---|---|
| Push body | You build { streams: [{ stream, values }] } and set the content-type header. | distributor.push(stream) and pushMany(streams) build the body and the header. |
| Nanosecond timestamps | You turn milliseconds into a nanosecond string, because JSON.stringify rejects a bigint. | A bigint timestamp is sent as a string, in the body and in the query string. nanoSeconds() returns the current time as a bigint. |
| LogQL in the query string | You URL-encode {app="api"} and every other parameter. | Query parameters are a typed object; the client encodes them. |
| Separate components | Distributor, querier and compactor can each have their own URL, and you keep track of them. | One options object with distributorURL, querierURL and compactorURL; each falls back to the client's baseURL. |
| Error statuses | fetch resolves on a 4xx or 5xx; you check response.ok. | A 4xx or 5xx rejects with an HttpResponseError that carries the status and the response body. |
| Result shape | await response.json() is any. | QuerierQueryResult, discriminated by data.resultType (streams or vector). |
The same push, two ways:
// fetch
const response = await fetch('http://localhost:3100/loki/api/v1/push', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
streams: [
{ stream: { app: 'api' }, values: [[`${Date.now()}000000`, 'user logged in']] },
],
}),
});
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}`);
}// @hapic/loki
import { createClient, nanoSeconds } from '@hapic/loki';
const client = createClient();
await client.distributor.push({
stream: { app: 'api' },
values: [[nanoSeconds(), 'user logged in']],
});Installation
npm install hapic @hapic/loki --save- ESM only (
import, norequire). - Node.js
>=22.0.0. The client is built onfetch, so it also runs in browsers and workers. hapicis a peer dependency:LokiClientextends itsClient, so install both.
Configuration
import { createClient } from '@hapic/loki';
const client = createClient({
request: {
baseURL: 'https://loki.example.com/',
headers: { 'X-Scope-OrgID': 'tenant-a' },
timeout: 10_000,
},
options: {
querierURL: 'https://loki-read.example.com/',
},
});createClient(config) and new LokiClient(config) take the same object:
| Key | Type | Description |
|---|---|---|
| request | ClientOptionsInput from hapic | Options of the underlying hapic client: baseURL, headers, timeout (milliseconds), proxy, transport and every fetch RequestInit option. baseURL defaults to http://localhost:3100/. The object you pass is copied, not modified. All options: The Client. |
| options | Options | Per-component base URLs, see Component base URLs. |
Headers in request.headers, or set later with client.setHeader(), go with every request. The inherited client.setAuthorizationHeader({ type: 'Basic', username, password }) (also Bearer and API-key forms) sets the Authorization header.
Component base URLs
When Loki runs as separate components, each domain object can send its requests to its own base URL:
import { createClient } from '@hapic/loki';
import type { Options } from '@hapic/loki';
const options: Options = {
distributorURL: 'http://distributor:3100/',
querierURL: 'http://querier:3100/',
};
const client = createClient({
request: { baseURL: 'http://loki-gateway:3100/' },
options,
});
// writes into the shared object: options.compactorURL is set now as well
client.compactor.setOption('compactorURL', 'http://compactor:3100/');| Option | Used by | Methods |
|---|---|---|
| distributorURL | client.distributor | push, pushMany |
| querierURL | client.querier | query, queryRange |
| compactorURL | client.compactor | createDeletionRequest, cancelDeletionRequest |
| ingesterURL, indexGatewayURL, querySchedulerURL, rulerURL | none | Accepted by the type; no method of this package reads them. |
- A request uses the option of its component when it is set and not empty, and the client's
baseURLotherwise. - All domain objects of one client share one options object: the object you pass as
options, or a new empty object when you pass none. They keep a reference, not a copy, so when you change your object, the next request goes to the new URL. setOption(key, value)on any domain object writeskeyinto that shared object, so every domain object of the client sees it.setOptions(options)replaces the options object of that one domain object (with an empty object when called without an argument). From then on it no longer shares options with the others, and a latersetOptionon another domain object does not reach it.- Options are read when a request is built, so a change applies from the next request on.
Using a domain object on its own
import { QuerierAPI } from '@hapic/loki';
const querier = new QuerierAPI({
client: { baseURL: 'http://querier:3100/' },
});Every domain class takes { client, options }. client is either an existing client (a LokiClient or any hapic client) or request options. With request options, or without client, the domain object creates a plain hapic client, which has no default base URL: the http://localhost:3100/ default belongs to LokiClient.
Distributor
client.distributor (DistributorAPI) pushes log entries with POST loki/api/v1/push, against distributorURL or the client's baseURL.
push
import { createClient, nanoSeconds } from '@hapic/loki';
const client = createClient();
await client.distributor.push({
stream: { app: 'api', env: 'production' },
values: [
[nanoSeconds(), 'user logged in'],
[nanoSeconds(), 'request completed', { trace_id: 'abc123' }],
],
});push(stream)sends one stream; it callspushMany([stream]).- A stream (
DistributorPushStream) has optionalstreamlabels (Record<string, string>) andvalues, a list of[timestamp, line]or[timestamp, line, metadata]tuples. The timestamp is a nanosecond epoch asstringorbigint;metadatais aRecord<string, string>sent as the third element. - The body is JSON, sent with
content-type: application/json. Abigintis written as a string (1000nbecomes"1000"). - Resolves to
undefinedonce Loki answers with a 2xx.
pushMany
import { createClient, nanoSeconds } from '@hapic/loki';
const client = createClient();
await client.distributor.pushMany([
{ stream: { app: 'web' }, values: [[nanoSeconds(), 'page rendered']] },
{ stream: { app: 'api' }, values: [[nanoSeconds(), 'db query ok']] },
]);- Sends all streams in one request as
{ "streams": [...] }. - Same encoding and result as
push.
Querier
client.querier (QuerierAPI) runs LogQL queries against querierURL or the client's baseURL. Both methods send accept: application/json, pass their options as query string parameters and return the parsed JSON body unchanged.
query
import { createClient } from '@hapic/loki';
const client = createClient();
const result = await client.querier.query({
query: '{app="api"} |= "error"',
limit: 100,
direction: 'backward',
});
if (result.data.resultType === 'streams') {
for (const entry of result.data.result) {
for (const [timestamp, line] of entry.values) {
console.log(entry.stream.app, timestamp, line);
}
}
}Sends GET loki/api/v1/query with these parameters (QuerierQueryOptions):
| Parameter | Type | Description |
|---|---|---|
| query | string | The LogQL query. Required. |
| limit | number | Maximum number of entries. |
| time | bigint \| string | Evaluation time, nanosecond Unix epoch. |
| direction | 'backward' \| 'forward' | Sort order. The QuerierQueryDirection enum holds both values. |
queryRange
import { createClient, nanoSeconds } from '@hapic/loki';
const client = createClient();
const end = nanoSeconds();
const result = await client.querier.queryRange({
query: '{app="api"}',
start: end - 3_600_000_000_000n, // one hour earlier
end,
limit: 500,
direction: 'forward',
});Sends GET loki/api/v1/query_range with query, limit and direction as above, plus (QuerierQueryRangeOptions):
| Parameter | Type | Description |
|---|---|---|
| start | number \| bigint \| string | Start of the range, nanosecond Unix epoch. |
| end | number \| bigint \| string | End of the range, nanosecond Unix epoch. |
| step | string | Step as a duration string, for example 30s. |
| interval | number | Only return entries at or above this interval, in seconds. |
Both methods resolve to a QuerierQueryResult:
statusis'success'or'error';data.resultTypeis'streams'or'vector'.- For
streams,data.resultis a list of{ stream, values }, wherevaluesare[timestamp, line]string pairs. - For
vector,data.resultis a list of{ metric, value }, wherevalueis[number, string]. - Parameters you leave out are not sent, so Loki applies its own defaults.
Compactor
client.compactor (CompactorAPI) manages deletion requests at loki/api/v1/delete, against compactorURL or the client's baseURL.
createDeletionRequest
import { createClient, nanoSeconds } from '@hapic/loki';
const client = createClient();
await client.compactor.createDeletionRequest({
query: '{app="api"}',
start: nanoSeconds() - 86_400_000_000_000n, // 24 hours earlier
});- Sends
POST loki/api/v1/deletewithquery(LogQL, required),start(required) andend(optional) as query string parameters, and an empty JSON body ({}) withcontent-type: application/json. startandendare nanosecond Unix epochs asnumber,bigintorstring.- Resolves to
undefined; the response body is not returned.
cancelDeletionRequest
import { createClient } from '@hapic/loki';
const client = createClient();
await client.compactor.cancelDeletionRequest({
request_id: 'd7a2b0c1',
force: true,
});- Sends
DELETE loki/api/v1/deletewithrequest_idand, when set,forceas query string parameters, and no body. request_idis astring,numberorbigint. Withforce: true, a partially completed request is cancelled as well.- Resolves to
undefined.
Timestamps
import { nanoSeconds } from '@hapic/loki';
const now: bigint = nanoSeconds();
const fiveMinutesAgo = now - 300_000_000_000n;nanoSeconds()returns the current time as abigintof nanoseconds since the Unix epoch, computed fromperformance.timeOriginplusperformance.now().- Pass timestamps as
bigintorstring. A nanosecond epoch (about1.8e18) is larger thanNumber.MAX_SAFE_INTEGER, so anumberloses precision even where the type allows one (start,end).
Client registry
import { createClient, setClient, useClient } from '@hapic/loki';
setClient(createClient({ request: { baseURL: 'https://loki.example.com/' } }));
// anywhere else
const client = useClient();| Function | Behaviour |
|---|---|
| createClient(config?) | Returns a new LokiClient. Does not register it. |
| useClient(key?) | Returns the client registered under key (default 'default'); on first use it creates and registers a LokiClient with default config. |
| setClient(client, key?) | Registers client under key and returns it. |
| hasClient(key?) | Whether a client is registered under key. |
| unsetClient(key?) | Removes the client registered under key. |
| isClient(value) | true for a LokiClient, also one created by another copy of the package; false for a plain hapic client. |
The default export is a LokiClient with default config, created when the module loads. It is not the registry's 'default' client: useClient() returns a different instance. More in Instance Registry.
Errors
import {
createClient,
isClientErrorWithStatusCode,
isHttpResponseError,
isNetworkError,
} from '@hapic/loki';
const client = createClient();
try {
await client.querier.query({ query: '{app="api"' });
} catch (error) {
if (isClientErrorWithStatusCode(error, 400)) {
// Loki rejected the query
}
if (isHttpResponseError(error)) {
console.error(error.status, error.response?.data);
} else if (isNetworkError(error)) {
console.error(error.code); // for example 'ETIMEDOUT', 'ECONNABORTED' or 'ECONNRESET'
}
}- A 4xx or 5xx response rejects with an
HttpResponseError:statusholds the status code,response.datathe decoded body. - A request that gets no response (connection failure, abort,
timeout) rejects with aNetworkError, whosecodenames the cause. - Both are
ClientErrors, and every error thrown is aHapicError. The guardsisHapicError,isClientError,isHttpResponseError,isNetworkError,isClientErrorWithStatusCodeandisClientErrorDueNetworkIssueare re-exported fromhapic, and they also recognise errors thrown by another copy of the package. - Error hooks registered with
client.on(...)can retry a failed request or answer it with a fallback response. See Error Handling and Hooks.
API
| Export | Kind | Purpose |
|---|---|---|
| LokiClient | class | hapic client with distributor, querier and compactor. |
| createClient, useClient, setClient, hasClient, unsetClient, isClient | functions | Client registry. |
| default export | LokiClient | A client with default config, created on import. |
| DistributorAPI | class | push, pushMany. |
| QuerierAPI | class | query, queryRange. |
| CompactorAPI | class | createDeletionRequest, cancelDeletionRequest. |
| QuerierQueryDirection | enum | BACKWARD = 'backward', FORWARD = 'forward'. |
| nanoSeconds | function | Current time as a nanosecond bigint, see Timestamps. |
| LOKI_CLIENT_INSTANCE | symbol | The marker isClient checks for. |
| HeaderName | enum | CONTENT_TYPE = 'Content-Type'. |
| Config, ConfigInput, Options | types | Client configuration. |
| DistributorPushStream | type | A stream for push and pushMany. |
| QuerierQueryOptions, QuerierQueryRangeOptions | types | Parameters of query and queryRange. |
| QuerierQueryResult, QuerierQueryStreamsResult, QuerierQueryVectorResult | types | Query results. |
| CompactorDeletionRequestCreate, CompactorDeletionRequestCancel | types | Parameters of the compactor methods. |
| isHapicError, isClientError, isHttpResponseError, isNetworkError, isClientErrorWithStatusCode, isClientErrorDueNetworkIssue | functions | Error guards, re-exported from hapic. |
| HapicError, ClientError, HttpResponseError, NetworkError, ErrorCode, RequestBaseOptions, Response, ResponseData | types | Re-exported from hapic as types only. Import ErrorCode from hapic to use its values. |
Notes
- Write methods do not return the response.
push,pushMany,createDeletionRequestandcancelDeletionRequestresolve toundefinedon a 2xx. Register aresponsehook if you need the raw response. - Each domain object talks to one component. Distributor methods use
distributorURL, querier methodsquerierURL, compactor methodscompactorURL, each falling back to the client'sbaseURL. The four other option keys are not used by any method. setOptionsdetaches a domain object. It replaces that domain object's options object. UsesetOption, or change your ownoptionsobject, to change a URL for all of them.connectionStringis ignored. TheConfigtype has aconnectionStringkey, butLokiClientdoes not read it. Setrequest.baseURLand the authorization header instead.- Client-wide
paramsdo not reach the querier and compactor. A per-requestparamsreplaces the client default as a whole, and these methods pass their own. A client-widequeryobject is still merged into every query string. - The default export and
useClient()are two clients. Configure the one you use. - Query results are typed for
streamsandvectoronly. The body is returned as Loki sends it; any otherresultTypearrives with a type that does not describe it. - Deletion parameters go into the query string, not into the JSON body, which is always
{}. - Timestamps as
numberlose precision. Usebigintorstring, for example fromnanoSeconds().
Contributing
Issues and pull requests are welcome in the hapic repository. Commits follow Conventional Commits, which drive the changelog and the releases.
License
Made with 💚
Published under MIT License.
