datanotes 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -5
- package/dist/api/client.d.cts +37 -4
- package/dist/api/client.d.ts +37 -4
- package/dist/api/index.cjs +388 -52
- package/dist/api/index.d.cts +3 -2
- package/dist/api/index.d.ts +3 -2
- package/dist/api/index.js +388 -52
- package/dist/api/mcp.cjs +6 -3
- package/dist/api/mcp.js +6 -3
- package/dist/api/otlp.d.cts +63 -15
- package/dist/api/otlp.d.ts +63 -15
- package/dist/api/redact.d.cts +25 -0
- package/dist/api/redact.d.ts +25 -0
- package/dist/api/tokens.d.cts +5 -0
- package/dist/api/tokens.d.ts +5 -0
- package/dist/api/tracing.d.cts +107 -0
- package/dist/api/tracing.d.ts +107 -0
- package/dist/datanotes.cjs +9380 -2554
- package/dist/docs/administration/limits.md +96 -12
- package/dist/docs/administration/run-the-service.md +18 -0
- package/dist/docs/api/operations/graph_neighbors.md +4 -2
- package/dist/docs/api/operations/graph_path.md +3 -3
- package/dist/docs/api/operations/graph_traverse.md +2 -2
- package/dist/docs/api/operations/record_tool_call.md +1 -0
- package/dist/docs/api/operations/tags/queries.md +2 -2
- package/dist/docs/api.md +3 -3
- package/dist/docs/develop/concurrency.md +36 -1
- package/dist/docs/develop/http-api.md +11 -1
- package/dist/docs/develop/telemetry.md +224 -25
- package/dist/docs/guides/data-quality/check-constraints.md +10 -10
- package/dist/docs/guides/data-quality/hand-edits.md +8 -8
- package/dist/docs/guides/database-objects/bases.md +11 -11
- package/dist/docs/guides/database-objects/catalogs-and-schemas.md +10 -10
- package/dist/docs/guides/database-objects/columns.md +38 -38
- package/dist/docs/guides/database-objects/relations.md +27 -27
- package/dist/docs/guides/database-objects/tables.md +37 -101
- package/dist/docs/guides/database-objects/views.md +44 -44
- package/dist/docs/guides/history/back-up.md +12 -4
- package/dist/docs/guides/history/change-data-feed.md +34 -34
- package/dist/docs/guides/history/journal-and-undo.md +12 -12
- package/dist/docs/guides/history/restore.md +20 -20
- package/dist/docs/guides/history/table-history.md +31 -31
- package/dist/docs/guides/history/vacuum.md +1 -10
- package/dist/docs/guides/read-and-write/automatic-links.md +15 -15
- package/dist/docs/guides/read-and-write/import-notes.md +20 -20
- package/dist/docs/guides/read-and-write/joins-and-aggregates.md +7 -7
- package/dist/docs/guides/read-and-write/read-and-filter.md +10 -10
- package/dist/docs/guides/read-and-write/write-rows.md +10 -10
- package/dist/docs/guides/search/link-graph.md +95 -0
- package/dist/docs/guides/search/text.md +1 -1
- package/dist/docs/guides/security/who-did-what.md +2 -0
- package/dist/docs/reference/cli.md +10 -3
- package/dist/docs/reference/compatibility.md +33 -13
- package/dist/docs/reference/operations/queries.md +3 -3
- package/dist/docs/reference/operations.md +11 -11
- package/dist/docs/reference/settings.md +37 -25
- package/dist/docs/release-notes.md +121 -0
- package/dist/docs/start/how-it-works.md +35 -3
- package/dist/docs/start/quick-start.md +2 -0
- package/package.json +3 -2
|
@@ -6,21 +6,86 @@ The engine keeps every row in a Markdown note, so its speed depends on the numbe
|
|
|
6
6
|
|
|
7
7
|
## Measured times
|
|
8
8
|
|
|
9
|
-
We measured
|
|
9
|
+
We measured Datanotes 3.1.0 on synthetic databases of 10,000 and 50,000 notes, made with `bench/scale/`. One machine ran every test:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
| `select_rows` with `where` | 30 to 35 ms | 178 ms |
|
|
16
|
-
| `insert_row` | 91 to 128 ms | 414 ms |
|
|
17
|
-
| Memory (resident set size) after writes | Not measured | Up to 2 GB |
|
|
11
|
+
* Windows 11 Pro on an Intel Core i7-13700HX, with 32 GB of memory and an NVMe SSD
|
|
12
|
+
* Node.js 24.21
|
|
13
|
+
* Microsoft Defender scanning every file, with no exclusion
|
|
14
|
+
* Other programs running at the same time
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
The times are medians. The first start reads every note and builds the [vault index](#start-faster-with-the-vault-index). A restart reads only the notes that changed.
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
| Measure | 10,000 notes | 50,000 notes |
|
|
19
|
+
| ----------------------------------------------------- | ------------ | ------------ |
|
|
20
|
+
| First start | 2.7 s | 15 s |
|
|
21
|
+
| Restart | 0.7 s | 2.0 to 2.3 s |
|
|
22
|
+
| First `select_rows` after a restart | 0.15 s | 1.1 to 2 s |
|
|
23
|
+
| Background work after a restart | 0.6 s | 4 to 6 s |
|
|
24
|
+
| Memory (resident set size) after a restart | 130 MB | 330 MB |
|
|
25
|
+
| Memory (resident set size) after a series of searches | 150 MB | 460 MB |
|
|
26
|
+
| Vault index on disk | 21 MB | 104 MB |
|
|
27
|
+
|
|
28
|
+
We measured the memory once the engine was idle, after a garbage collection, with the read cache off.
|
|
29
|
+
|
|
30
|
+
At 50,000 notes, the operations took these times:
|
|
31
|
+
|
|
32
|
+
| Operation | Median time |
|
|
33
|
+
| ---------------------------------------------- | ----------- |
|
|
34
|
+
| `insert_row` | 37 ms |
|
|
35
|
+
| `update_row` | 47 ms |
|
|
36
|
+
| `select_rows` on a table of 117 rows | 2.8 ms |
|
|
37
|
+
| `select_rows` of 5 notes by path | 0.3 ms |
|
|
38
|
+
| `describe_database` | 24 ms |
|
|
39
|
+
| `graph_neighbors` right after a write | 2.5 ms |
|
|
40
|
+
| `search_notes`, two words found in 1,213 notes | 14 ms |
|
|
41
|
+
| `search_notes`, a person’s name | 7 ms |
|
|
42
|
+
| `search_notes`, a word found in 15,905 notes | 140 ms |
|
|
43
|
+
|
|
44
|
+
The first `select_rows` waits until the engine has compared the vault with its index. These numbers set the size the engine handles well:
|
|
45
|
+
|
|
46
|
+
* Up to about 10,000 notes, it starts within 3 seconds and answers within a few hundred milliseconds
|
|
47
|
+
* From 10,000 to 50,000 notes, start it before your agents and keep it running
|
|
48
|
+
* Above about 20,000 notes, run it as a service, and give it 2 GB of memory
|
|
49
|
+
|
|
50
|
+
## Start faster with the vault index
|
|
51
|
+
|
|
52
|
+
The engine keeps a vault index in its store, in `index/vault.sqlite`. It records what the engine read from each note: its properties, tags, links and text. It also indexes the words of each note, so `search_notes` reads only the notes that can match. It keeps each text compressed. At the next start, the engine reads only the notes that changed since.
|
|
53
|
+
|
|
54
|
+
* A note counts as unchanged while its size, its modification time and its change time stay the same.
|
|
55
|
+
* The change time moves with every write, also one that restores the old modification time, as `cp -p` and `rsync -t` do.
|
|
56
|
+
* A note written within 2 seconds of being recorded is read again. Some file systems keep times to the second only.
|
|
57
|
+
* A note you moved while the engine was stopped is found by its file id, or by its content.
|
|
58
|
+
|
|
59
|
+
The index is a cache, never the source of truth. Keep these points in mind:
|
|
60
|
+
|
|
61
|
+
* You can delete it at any time. The next start builds it again.
|
|
62
|
+
* The store backup leaves it out, and so does Time Machine on macOS.
|
|
63
|
+
* It needs Node.js 22.13 or later. On an older Node.js, every start reads every note.
|
|
64
|
+
* After each start, the engine checks the index in the background, one table at a time.
|
|
65
|
+
* When SQLite or that check finds damage, the engine stops using the index until the next start rebuilds it.
|
|
66
|
+
* A query with only one- or two-letter words reads every note.
|
|
67
|
+
|
|
68
|
+
To turn it off, set `vaultIndexEnabled` to `false` in the [engine’s settings](https://datanotes.dev/administration/configuration/). The engine then deletes the index, and every start reads every note.
|
|
69
|
+
|
|
70
|
+
## Run a large database on Windows
|
|
71
|
+
|
|
72
|
+
Microsoft Defender scans every file the engine opens. On a large vault, these scans take most of the start. The engine marks its store as not content indexed, so Windows Search skips it. It never changes your antivirus settings.
|
|
73
|
+
|
|
74
|
+
To speed up the engine, choose one of these options:
|
|
75
|
+
|
|
76
|
+
* Exclude the store folder from real-time scanning. Run this in a PowerShell window opened as administrator:
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\Datanotes"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
* Exclude the vault folder too, but only when its notes come from sources you trust.
|
|
83
|
+
|
|
84
|
+
* Put the vault and the data home on a Dev Drive. Its performance mode defers the scans.
|
|
85
|
+
|
|
86
|
+
Never exclude the `node.exe` process: that exempts every Node.js program on the machine.
|
|
87
|
+
|
|
88
|
+
Keep the store out of OneDrive, Dropbox and iCloud folders. Sync clients and scanners lock files, and the engine then retries its renames. On macOS, the engine writes a `.metadata_never_index` file in its store, so Spotlight skips it. It also keeps the vault index out of Time Machine, since the engine can rebuild it. Time Machine still backs up the rest of the store.
|
|
24
89
|
|
|
25
90
|
## Start the engine before the agent
|
|
26
91
|
|
|
@@ -67,3 +132,22 @@ volumes:
|
|
|
67
132
|
```
|
|
68
133
|
|
|
69
134
|
On a Linux host, a bind mount delivers file events: set `DATANOTES_WATCH=events` there too. [Run in Docker](https://datanotes.dev/administration/docker/) explains the container.
|
|
135
|
+
|
|
136
|
+
## Check the engine’s indexes
|
|
137
|
+
|
|
138
|
+
The engine keeps indexes of the vault in memory, so a read does not go through every note. It learns each change from the vault’s file events: its own writes, your edits, renames and deletes. It never trusts them over the notes, which stay the source of truth.
|
|
139
|
+
|
|
140
|
+
When an answer looks wrong after many changes outside the engine, check the indexes. Start the engine with `DATANOTES_INDEX_CHECK=1`:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
DATANOTES_INDEX_CHECK=1 \
|
|
144
|
+
npx -y datanotes serve --database your_database_folder
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
After every operation, the engine then compares its indexes with a full read of the vault:
|
|
148
|
+
|
|
149
|
+
* A difference shows as an error in the engine’s log, with the notes it concerns.
|
|
150
|
+
* The engine then reads the vault again, so the next operations answer right.
|
|
151
|
+
* Each comparison reads every note, so keep the check for debugging, never in daily use.
|
|
152
|
+
|
|
153
|
+
Every 10 minutes, once no write ran for a minute, the engine also compares the transaction log’s view of the rows with the vault. A difference shows as a warning, and the engine logs the changes it missed.
|
|
@@ -22,6 +22,24 @@ The first start creates the engine’s store and its settings file, `settings.js
|
|
|
22
22
|
npx -y datanotes store path --database your_database_folder
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Add `--json` to get the answer as an object:
|
|
26
|
+
|
|
27
|
+
| Field | Meaning |
|
|
28
|
+
| ----------------- | ----------------------------------------------------------------------------- |
|
|
29
|
+
| `vault` | The folder you gave, as an absolute path |
|
|
30
|
+
| `store` | The store folder |
|
|
31
|
+
| `database_id` | The database’s id |
|
|
32
|
+
| `database_folder` | The database folder, relative to the folder you gave; empty with `--database` |
|
|
33
|
+
| `database_path` | The database folder on disk, with `metastore.yaml` at its root |
|
|
34
|
+
|
|
35
|
+
Every field but `vault` is `null` when the engine cannot tell where the store would be. That happens only when all three hold:
|
|
36
|
+
|
|
37
|
+
* You pass no `--store`, and `DATANOTES_STORE` is not set.
|
|
38
|
+
* This machine never ran the engine on the folder.
|
|
39
|
+
* `metastore.yaml` has no id yet.
|
|
40
|
+
|
|
41
|
+
With `--store`, only `database_id` can be `null`: neither the store nor `metastore.yaml` records the id.
|
|
42
|
+
|
|
25
43
|
To run the engine in a container, follow [Run in Docker](https://datanotes.dev/administration/docker/).
|
|
26
44
|
|
|
27
45
|
## Where the engine keeps its store
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
Direct neighbours of notes: what they link to (`out`) and what links to them (`in`,
|
|
6
6
|
backlinks), with counts per edge label. `direction`: out, in or both (default).
|
|
7
7
|
|
|
8
|
-
`from_nodes` selects the notes: `{"paths": [...]}
|
|
9
|
-
|
|
8
|
+
`from_nodes` selects the notes: `{"paths": [...]}`, `{"table": ..., "where": {...}}` and/or
|
|
9
|
+
`{"tables": [...]}`: the rows of several tables, by name or pattern (`["*"]` every table, `["personal.*"]` a
|
|
10
|
+
catalog's). The whole vault's graph in one call: `{"tables": ["*"]}` with direction `out` (at most 5000
|
|
11
|
+
start notes). Edges are labelled with their link column (`people`, `event`, …) or `link` for other links.
|
|
10
12
|
|
|
11
13
|
## Request body
|
|
12
14
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Shortest chain of links between two sets of notes (each `{"paths": [...]}`
|
|
1
|
+
# Shortest chain of links between two sets of notes (each `{"paths": [...]}`, `{"table", "where"}`
|
|
2
2
|
|
|
3
3
|
`POST /datanotes/operations/graph_path/` (operation `graph_path`)
|
|
4
4
|
|
|
5
|
-
Shortest chain of links between two sets of notes (each `{"paths": [...]}`
|
|
6
|
-
`{"
|
|
5
|
+
Shortest chain of links between two sets of notes (each `{"paths": [...]}`, `{"table", "where"}`
|
|
6
|
+
and/or `{"tables": [...]}`): `found`, `length` and `path`: the steps (`from`, `label`, `direction`, `to`).
|
|
7
7
|
`direction` both by default; `max_depth` default 6 (max 10).
|
|
8
8
|
|
|
9
9
|
## Request body
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Breadth-first walk over links from `from_nodes` (`{"paths": [...]}
|
|
1
|
+
# Breadth-first walk over links from `from_nodes` (`{"paths": [...]}`, `{"table", "where"}` and/or `{"tables": [...]}`).
|
|
2
2
|
|
|
3
3
|
`POST /datanotes/operations/graph_traverse/` (operation `graph_traverse`)
|
|
4
4
|
|
|
5
|
-
Breadth-first walk over links from `from_nodes` (`{"paths": [...]}
|
|
5
|
+
Breadth-first walk over links from `from_nodes` (`{"paths": [...]}`, `{"table", "where"}` and/or `{"tables": [...]}`).
|
|
6
6
|
|
|
7
7
|
- `direction`: out (default, follow links), in (follow backlinks) or both.
|
|
8
8
|
- `edges`: edge labels to follow (link columns such as `people`, `event`, or `link`).
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
| `session` | `string` | no | | |
|
|
24
24
|
| `result_chars` | `number` | no | | |
|
|
25
25
|
| `result_tokens` | `number` | no | | |
|
|
26
|
+
| `summary` | `object` | no | | The result's summary, when `value` is sent already cut: the engine records it (the answer says `takes_summary`). |
|
|
26
27
|
|
|
27
28
|
## Responses
|
|
28
29
|
|
|
@@ -11,5 +11,5 @@ Operations that read rows and notes: select_rows, link targets, text and semanti
|
|
|
11
11
|
- [The rows a link column may point to, for one row: what an agent or a picker offers before writing](https://datanotes.dev/api/operations/list_link_targets/): `POST /datanotes/operations/list_link_targets/`
|
|
12
12
|
- [Find notes by meaning, not exact words: `{query, results: [{path, score, heading, snippet,](https://datanotes.dev/api/operations/semantic_search/): `POST /datanotes/operations/semantic_search/`
|
|
13
13
|
- [Direct neighbours of notes: what they link to (`out`) and what links to them (`in`,](https://datanotes.dev/api/operations/graph_neighbors/): `POST /datanotes/operations/graph_neighbors/`
|
|
14
|
-
- [Breadth-first walk over links from `from_nodes` (`{"paths": [...]}
|
|
15
|
-
- [Shortest chain of links between two sets of notes (each `{"paths": [...]}`
|
|
14
|
+
- [Breadth-first walk over links from `from_nodes` (`{"paths": [...]}`, `{"table", "where"}` and/or `{"tables": [...]}`).](https://datanotes.dev/api/operations/graph_traverse/): `POST /datanotes/operations/graph_traverse/`
|
|
15
|
+
- [Shortest chain of links between two sets of notes (each `{"paths": [...]}`, `{"table", "where"}`](https://datanotes.dev/api/operations/graph_path/): `POST /datanotes/operations/graph_path/`
|
package/dist/docs/api.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Datanotes HTTP API
|
|
2
2
|
|
|
3
|
-
> OpenAPI 3.1.0 description of the Datanotes 3.
|
|
3
|
+
> OpenAPI 3.1.0 description of the Datanotes 3.1.0 HTTP API: https://datanotes.dev/openapi.json
|
|
4
4
|
|
|
5
5
|
Database of Markdown notes: tables of notes, rows, queries, history, a change feed and the vault's notes.
|
|
6
6
|
|
|
@@ -69,8 +69,8 @@ Operations that read rows and notes: select_rows, link targets, text and semanti
|
|
|
69
69
|
- [The rows a link column may point to, for one row: what an agent or a picker offers before writing](https://datanotes.dev/api/operations/list_link_targets/): `POST /datanotes/operations/list_link_targets/`
|
|
70
70
|
- [Find notes by meaning, not exact words: `{query, results: [{path, score, heading, snippet,](https://datanotes.dev/api/operations/semantic_search/): `POST /datanotes/operations/semantic_search/`
|
|
71
71
|
- [Direct neighbours of notes: what they link to (`out`) and what links to them (`in`,](https://datanotes.dev/api/operations/graph_neighbors/): `POST /datanotes/operations/graph_neighbors/`
|
|
72
|
-
- [Breadth-first walk over links from `from_nodes` (`{"paths": [...]}
|
|
73
|
-
- [Shortest chain of links between two sets of notes (each `{"paths": [...]}`
|
|
72
|
+
- [Breadth-first walk over links from `from_nodes` (`{"paths": [...]}`, `{"table", "where"}` and/or `{"tables": [...]}`).](https://datanotes.dev/api/operations/graph_traverse/): `POST /datanotes/operations/graph_traverse/`
|
|
73
|
+
- [Shortest chain of links between two sets of notes (each `{"paths": [...]}`, `{"table", "where"}`](https://datanotes.dev/api/operations/graph_path/): `POST /datanotes/operations/graph_path/`
|
|
74
74
|
|
|
75
75
|
### History
|
|
76
76
|
|
|
@@ -24,7 +24,42 @@ Every client goes through that engine:
|
|
|
24
24
|
|
|
25
25
|
## Writes run one at a time
|
|
26
26
|
|
|
27
|
-
The engine
|
|
27
|
+
The engine runs writes one at a time, in the order they arrive, under the lock of its transaction log. Reads run in parallel with each other.
|
|
28
|
+
|
|
29
|
+
A write that still runs after a minute lets the next write start, so one stuck write can’t hold up the rest. The engine logs a warning that names the operation.
|
|
30
|
+
|
|
31
|
+
## Reads wait for writes
|
|
32
|
+
|
|
33
|
+
The engine runs on one thread. Under a stream of reads, a write would share that thread with every read that arrives while it runs, and take several times longer. So writes go first:
|
|
34
|
+
|
|
35
|
+
* **A write runs or waits**: a read that arrives waits until no write is left.
|
|
36
|
+
* **A read already under way**: it finishes normally. The write doesn’t wait for it.
|
|
37
|
+
* **A read waits at most `writePriorityMaxWaitMs`**: 100 ms by default. It also starts once 8 writes started after it came. A burst of writes never starves the reads.
|
|
38
|
+
* **A long poll of the change feed**: `list_changes` with `wait_ms` never waits, and never holds a write.
|
|
39
|
+
|
|
40
|
+
What each operation does is unchanged: a read sees every write that ended before it started. Set `writePriority` to `false` in the [settings](https://datanotes.dev/reference/settings/) to let reads and writes start in the order they arrive. The span `engine.schedule.wait` shows the wait in [traces](https://datanotes.dev/develop/telemetry/#the-spans-the-engine-sends).
|
|
41
|
+
|
|
42
|
+
## Repeated reads come from memory
|
|
43
|
+
|
|
44
|
+
A read you send again with the same arguments, with the same token, often has the same answer. The engine keeps the answers of recent reads in memory, and sends the kept answer while nothing it read has changed:
|
|
45
|
+
|
|
46
|
+
* **A write**: a read of the tables it changed is computed again. Reads of other tables keep their answers.
|
|
47
|
+
* **Undo, a hand edit on disk, a sync tool**: they count as writes. The engine sees them before it answers.
|
|
48
|
+
* **A schema change**: every answer is computed again.
|
|
49
|
+
* **A view**: it depends on the tables its query reads.
|
|
50
|
+
|
|
51
|
+
Some reads depend on less than a table, or on more:
|
|
52
|
+
|
|
53
|
+
* **`read_note`**: it depends on its note. Deleting or renaming a folder also counts.
|
|
54
|
+
* **`list_folder`**: it depends on the notes and folders created, deleted or renamed in its folder. Deleting or renaming any folder also counts.
|
|
55
|
+
* **`list_tables`**: it depends on the schema only. A write to a table keeps its answer.
|
|
56
|
+
* **`describe_database`**: it depends on every table, and on the notes whose properties can’t be read.
|
|
57
|
+
* **A filter through a link column**: a key such as `project.status` follows the link column `project`. The read also depends on the tables the link leads to.
|
|
58
|
+
* **Reads that follow links or search the text**: `expand`, `within`, the graph operations and `search_notes` depend on the whole vault.
|
|
59
|
+
|
|
60
|
+
Reads that compare with the current time (`{now}`) are never kept. A read with `{today}` is kept 15 minutes at most. Reads of history, of the change feed and of `system.information_schema` are never kept either.
|
|
61
|
+
|
|
62
|
+
`readCacheMaxBytes` sets the memory for the answers: 33554432 bytes (32 MiB) by default. The least recently used answers go first. Set it to `0` to turn the cache off.
|
|
28
63
|
|
|
29
64
|
The engine checks each write against the database as the writes before it left it. A write is checked first, then applied. If another write is applied while the engine checks yours, the engine checks yours again on the new state. It tries up to 5 times before it gives up.
|
|
30
65
|
|
|
@@ -32,7 +32,7 @@ An operation’s body takes the same arguments as the agent’s tool of the same
|
|
|
32
32
|
* **[`list_folder`](https://datanotes.dev/reference/operations/queries/#list_folder)**: the notes and folders in a `folder`
|
|
33
33
|
* **[`search_notes`](https://datanotes.dev/reference/operations/queries/#search_notes)**: the notes that contain a `query`
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
Routes and fields follow semantic versioning: see [Compatibility](https://datanotes.dev/reference/compatibility/).
|
|
36
36
|
|
|
37
37
|
## Record who makes each write
|
|
38
38
|
|
|
@@ -72,6 +72,16 @@ Several clients can write at the same time. [Concurrent writes](https://datanote
|
|
|
72
72
|
|
|
73
73
|
The engine binds to `127.0.0.1` unless you start it with `--host`. It answers no Cross-Origin Resource Sharing (CORS) preflight, so web pages can’t call it. To reach it from another device, follow the [security model](https://datanotes.dev/guides/security/security-model/).
|
|
74
74
|
|
|
75
|
+
## Get large answers compressed
|
|
76
|
+
|
|
77
|
+
A client on another machine gets answers of 16 KB or more compressed with gzip. It asks for that with the `Accept-Encoding: gzip` header, as most HTTP clients do. A client on the engine’s machine gets them as they are: compressing would cost the engine more time than it saves.
|
|
78
|
+
|
|
79
|
+
The `httpCompression` [setting](https://datanotes.dev/reference/settings/) changes this:
|
|
80
|
+
|
|
81
|
+
* **`remote`**: clients on other machines only (the default).
|
|
82
|
+
* **`always`**: this machine’s clients too.
|
|
83
|
+
* **`off`**: no compression.
|
|
84
|
+
|
|
75
85
|
## Generate a client or explore the routes
|
|
76
86
|
|
|
77
87
|
The [HTTP API reference](https://datanotes.dev/api/) documents every route, generated from the same OpenAPI description, [openapi.json](https://datanotes.dev/openapi.json). From JavaScript, use the [JavaScript client](https://datanotes.dev/develop/client/). Agents use the [Model Context Protocol (MCP) server](https://datanotes.dev/start/connect-agents/).
|
|
@@ -1,77 +1,276 @@
|
|
|
1
|
-
# Send logs to your observability backend
|
|
1
|
+
# Send logs and traces to your observability backend
|
|
2
2
|
|
|
3
|
-
> Send the logs of the engine, the MCP server and the apps to any OpenTelemetry backend.
|
|
3
|
+
> Send the logs and traces of the engine, the MCP server and the apps to any OpenTelemetry backend, and find slow operations.
|
|
4
4
|
|
|
5
|
-
Every part of Datanotes sends
|
|
5
|
+
Every part of Datanotes sends logs and traces with OpenTelemetry, the vendor-neutral standard. Any backend with an OpenTelemetry Protocol (OTLP) endpoint can receive them. This page shows you how to:
|
|
6
|
+
|
|
7
|
+
* Point the engine, the Model Context Protocol (MCP) server and the apps at your backend
|
|
8
|
+
* Read the spans the engine sends, and what each one measures
|
|
9
|
+
* Follow one agent call across the MCP server and the engine
|
|
10
|
+
* Find the operations that are slow, and why
|
|
11
|
+
|
|
12
|
+
Datanotes has no vendor-specific code.
|
|
6
13
|
|
|
7
14
|
## What Datanotes sends and where
|
|
8
15
|
|
|
9
|
-
Datanotes sends
|
|
16
|
+
Datanotes sends two signals over HTTP:
|
|
17
|
+
|
|
18
|
+
* **Logs** to `your_endpoint/v1/logs`
|
|
19
|
+
* **Traces** to `your_endpoint/v1/traces`
|
|
20
|
+
|
|
21
|
+
It encodes them as `http/protobuf`, the OpenTelemetry default, or as `http/json`. Any OpenTelemetry Collector receives them, and so do these backends, among others:
|
|
10
22
|
|
|
11
23
|
* SigNoz
|
|
12
|
-
* Grafana: Alloy, Loki or Grafana Cloud
|
|
24
|
+
* Grafana: Alloy, Tempo, Loki or Grafana Cloud
|
|
13
25
|
* Elastic, Datadog, Honeycomb, New Relic and Dynatrace
|
|
14
26
|
|
|
15
|
-
Datanotes sends no
|
|
27
|
+
Datanotes sends no metrics: your backend computes rates and latency percentiles from the spans. For a backend that accepts only gRPC, put an OpenTelemetry Collector in front of it. When the collector doesn’t answer, Datanotes counts the records and spans as lost and keeps working.
|
|
16
28
|
|
|
17
29
|
## Configure the export with environment variables
|
|
18
30
|
|
|
19
31
|
The engine, the MCP server and the apps all read the standard OpenTelemetry variables:
|
|
20
32
|
|
|
21
|
-
| Variable
|
|
22
|
-
|
|
|
23
|
-
| `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
24
|
-
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
33
|
+
| Variable | Meaning |
|
|
34
|
+
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | The collector’s base address, for example `http://127.0.0.1:4318` |
|
|
36
|
+
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Or the full `…/v1/logs` address |
|
|
37
|
+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Or the full `…/v1/traces` address |
|
|
38
|
+
| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value,key2=value2`, with URL-encoded values: the API key or `Authorization` your backend asks for |
|
|
39
|
+
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` (default) or `http/json` |
|
|
40
|
+
| `OTEL_SERVICE_NAME` | The service name; defaults to `datanotes`, `datanotes-mcp` (for `datanotes mcp`) or the app’s name |
|
|
41
|
+
| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes, for example `team=home` |
|
|
42
|
+
| `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG` | `traceidratio` with a share from 0 to 1, `always_on` or `always_off`, plain or with the `parentbased_` prefix |
|
|
43
|
+
| `OTEL_SDK_DISABLED=true` | Turns all the export off |
|
|
44
|
+
| `OTEL_LOGS_EXPORTER=none` or `OTEL_TRACES_EXPORTER=none` | Turns off the logs or the traces only |
|
|
30
45
|
|
|
31
|
-
Datanotes also reads the `…_LOGS_…` variants of the headers and protocol variables. Without an endpoint, it sends nothing.
|
|
46
|
+
Datanotes also reads the `…_LOGS_…` and `…_TRACES_…` variants of the headers and protocol variables. Without an endpoint, it sends nothing.
|
|
32
47
|
|
|
33
48
|
## Configure the engine in its settings file
|
|
34
49
|
|
|
35
|
-
Instead of the variables, you can configure the engine in its settings file, `settings.json` in its store
|
|
50
|
+
Instead of the variables, you can configure the engine in its settings file, `settings.json` in its store. `datanotes store path` shows where. A variable you set overrides the matching setting:
|
|
36
51
|
|
|
37
52
|
```json
|
|
38
53
|
{
|
|
39
54
|
"telemetryEnabled": true,
|
|
40
55
|
"telemetryEndpoint": "http://127.0.0.1:4318",
|
|
41
56
|
"telemetryServiceName": "datanotes",
|
|
57
|
+
"telemetryEnvironment": "home",
|
|
42
58
|
"telemetryProtocol": "http/protobuf",
|
|
43
|
-
"telemetryHeaders": {}
|
|
59
|
+
"telemetryHeaders": {},
|
|
60
|
+
"telemetryTraces": true,
|
|
61
|
+
"telemetryTraceSampleRatio": 1,
|
|
62
|
+
"telemetryMaxSpansPerTrace": 1000,
|
|
63
|
+
"telemetrySlowSpanMs": 0
|
|
44
64
|
}
|
|
45
65
|
```
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
Each setting does one thing:
|
|
68
|
+
|
|
69
|
+
* **`telemetryEnabled`** turns the export of logs and traces on.
|
|
70
|
+
* **`telemetryEnvironment`** names the environment, such as `dev` or `home`. The engine sends it as `deployment.environment.name` and as the older `deployment.environment`, which some backends group by.
|
|
71
|
+
* **`telemetryHeaders`** holds what your backend asks for, such as an API key. To keep a key out of the file, set `OTEL_EXPORTER_OTLP_HEADERS` instead.
|
|
72
|
+
* **`telemetryTraces`** sends the traces too. It is on by default: turning telemetry on gives you both signals.
|
|
73
|
+
* **`telemetryTraceSampleRatio`** keeps that share of new traces, from 0 to 1. The default, 1, keeps every trace. A trace that a caller started follows the caller’s choice.
|
|
74
|
+
* **`telemetryMaxSpansPerTrace`** caps the spans of one trace. The engine drops the rest and counts them in `datanotes.spans_dropped` on the trace’s first span.
|
|
75
|
+
* **`telemetrySlowSpanMs`** logs a request, an operation or a background job as a WARN when it lasts at least that many milliseconds. 0 turns it off.
|
|
76
|
+
|
|
77
|
+
The engine reads the settings again while it runs, so a change applies within a few seconds. With traces off, tracing costs the engine one check per instrumented call.
|
|
48
78
|
|
|
49
79
|
## What the engine logs
|
|
50
80
|
|
|
51
81
|
The engine logs these events:
|
|
52
82
|
|
|
53
83
|
* Every HTTP request and operation, with the route, duration, outcome and actor
|
|
54
|
-
* The tool calls MCP servers report, with the tool, duration, result size
|
|
84
|
+
* The tool calls MCP servers report, with the tool, duration, result size and client
|
|
55
85
|
* Refused requests
|
|
56
86
|
* The semantic index and git sync
|
|
57
87
|
* Errors
|
|
58
88
|
|
|
89
|
+
A record made during a traced request names its trace. The OTLP record carries the trace and span IDs, so your backend links the log to the trace. The log files carry them as `trace_id` and `span_id`. While a request’s trace is sent, its INFO records become DEBUG records: the span already says what they say.
|
|
90
|
+
|
|
59
91
|
Whatever the collector does, the engine also writes its records to files in `logs/`, in its store outside the vault. The `fileLogEnabled` setting turns these files on or off, and `logRetentionDays` sets how long the engine keeps them.
|
|
60
92
|
|
|
93
|
+
## The spans the engine sends
|
|
94
|
+
|
|
95
|
+
A trace is a tree of spans. Each span measures one piece of work and names its parent. A request to the engine gives this tree:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
POST /datanotes/operations/insert_row/ SERVER: the HTTP request
|
|
99
|
+
└── engine.op insert_row the operation
|
|
100
|
+
├── engine.schedule.wait a read waits for writes (only then)
|
|
101
|
+
├── engine.settle wait for the vault to settle
|
|
102
|
+
└── engine.insert_row the engine's work
|
|
103
|
+
├── engine.autolink links found in the text
|
|
104
|
+
├── engine.checks CHECKs and link rules
|
|
105
|
+
├── engine.lock.acquire wait for the write lock
|
|
106
|
+
└── engine.lock.hold work under the lock
|
|
107
|
+
├── engine.transaction_log hand edits logged first
|
|
108
|
+
├── engine.apply files written
|
|
109
|
+
│ └── engine.journal.append the undo entry
|
|
110
|
+
└── engine.transaction_log the operation's commit
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A read gives a similar tree under `engine.query.execute`:
|
|
114
|
+
|
|
115
|
+
* **`engine.query.view`** runs the stored query of a view.
|
|
116
|
+
* **`engine.query.plan`** resolves dates and `within` and compiles the filter.
|
|
117
|
+
* **`engine.query.scan`** reads every row of the table. Its `datanotes.query.plan` is `full_scan`: Datanotes has no indexes, and a file cache spares the reads of unchanged notes.
|
|
118
|
+
* **`engine.query.filter`**, **`engine.query.join`**, **`engine.query.group`**, **`engine.query.sort`** and **`engine.query.project`** follow, as the query needs them.
|
|
119
|
+
|
|
120
|
+
Other spans cover the rest of the engine’s time:
|
|
121
|
+
|
|
122
|
+
* **`engine.metastore.parse`** when `metastore.yaml` changed and the engine parses it again
|
|
123
|
+
|
|
124
|
+
* **`engine.history.read`** for a read as of a version or a time
|
|
125
|
+
|
|
126
|
+
* **`engine.graph.build`** when a graph operation rebuilds the link graph after a change. Calls that need it at the same time wait for that one build and count it in `datanotes.graph.build_waits`.
|
|
127
|
+
|
|
128
|
+
* **`engine.change_feed.record`** for the change feed’s events of an operation
|
|
129
|
+
|
|
130
|
+
* **`POST`** (CLIENT) for each webhook delivery, with `url.full`, `server.address` and `http.response.status_code`
|
|
131
|
+
|
|
132
|
+
* **`job <name>`** for background work. The jobs are:
|
|
133
|
+
|
|
134
|
+
* `job hand_edits_log`
|
|
135
|
+
* `job adopt_note`
|
|
136
|
+
* `job schema_reload`
|
|
137
|
+
* `job semantic_index`
|
|
138
|
+
* `job store_maintenance`
|
|
139
|
+
* `job change_feed_prune`
|
|
140
|
+
* `job change_feed_hand_edits`
|
|
141
|
+
* `job transaction_log_warmup`
|
|
142
|
+
* `job auto_recompute`
|
|
143
|
+
* `job inverse_links`
|
|
144
|
+
* `job datanotes_start`, the engine’s start
|
|
145
|
+
|
|
146
|
+
Background work is always a trace of its own, even when a request saw the change behind it. After a start, `job transaction_log_warmup` reads each table’s transaction log once, one table at a time, so a write waits for one table at most.
|
|
147
|
+
|
|
148
|
+
### Attributes of the operation span
|
|
149
|
+
|
|
150
|
+
The `engine.op <operation>` span follows the conventions of database spans. Most backends list these spans as database calls:
|
|
151
|
+
|
|
152
|
+
| Attribute | Meaning |
|
|
153
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
154
|
+
| `db.system.name`, `db.system` | `datanotes` |
|
|
155
|
+
| `db.operation.name` | The operation, such as `select_rows` |
|
|
156
|
+
| `db.collection.name` | The table or view the call names |
|
|
157
|
+
| `db.namespace` | The catalog and schema, when the call gives a full name |
|
|
158
|
+
| `db.query.summary` | The operation and its table, such as `select_rows task` |
|
|
159
|
+
| `db.response.returned_rows` | The rows the call returned |
|
|
160
|
+
| `datanotes.rows_scanned` | The rows the engine read to answer |
|
|
161
|
+
| `datanotes.notes_changed`, `datanotes.files_created` | What a write changed |
|
|
162
|
+
| `datanotes.migration_id` | The write’s journal entry, for `undo_migration` |
|
|
163
|
+
| `datanotes.dry_run` | Whether the call was a dry run |
|
|
164
|
+
| `datanotes.long_poll` | `true` for a `list_changes` call that waits for changes |
|
|
165
|
+
| `datanotes.schedule.class` | `read`, `write` or `free`: how [write priority](https://datanotes.dev/develop/concurrency/#reads-wait-for-writes) treats the call |
|
|
166
|
+
| `datanotes.schedule.wait_ms` | How long the call waited for writes before it started |
|
|
167
|
+
| `datanotes.schedule.released` | Why a waiting read started: `writes_done`, `max_wait` or `max_writes` |
|
|
168
|
+
| `datanotes.read_cache` | For a read: `hit` (answered from memory), `miss` or `not_kept` ([read cache](https://datanotes.dev/develop/concurrency/#repeated-reads-come-from-memory)) |
|
|
169
|
+
| `error.type` | Why the call failed or was refused |
|
|
170
|
+
|
|
171
|
+
The SERVER span adds `http.request.method`, `http.route`, `http.response.status_code`, `client.address` and `user_agent.original`. It also names the caller: `datanotes.token.name` holds the token’s name, never the token. `datanotes.client.harness` and `datanotes.client.model` hold what the agent says of itself.
|
|
172
|
+
|
|
173
|
+
### Calls to the vault
|
|
174
|
+
|
|
175
|
+
Reading a table of thousands of notes means thousands of file reads. The engine doesn’t make a span of each one. It adds them up on the span that made them and on its operation span:
|
|
176
|
+
|
|
177
|
+
* `datanotes.io.read_count`, `datanotes.io.read_chars` and `datanotes.io.read_ms`
|
|
178
|
+
* The same for `write`, `create`, `list`, `exists`, `rename` and the other calls
|
|
179
|
+
* `datanotes.cache.hits` and `datanotes.cache.misses` of the file cache
|
|
180
|
+
* `datanotes.cache.text_reads` and `datanotes.cache.text_source`: note texts the file cache read again, or took from the [vault index](https://datanotes.dev/administration/limits/#start-faster-with-the-vault-index)
|
|
181
|
+
* `datanotes.validate.rows` and `datanotes.validate.ms`, `datanotes.checks.rows` and `datanotes.checks.ms` for the checks run once per row
|
|
182
|
+
|
|
183
|
+
`_chars` counts characters, not bytes: the length of the text as JavaScript counts it, in UTF-16 code units. In English Markdown, characters and bytes are about the same. Accents, other alphabets and emoji take more bytes than characters. For `list`, `_chars` counts the files and folders listed. The calls that move no text count 0.
|
|
184
|
+
|
|
185
|
+
A single call that takes 20 ms or more gets a span of its own, `vault.<call>`, with its `file.path` and `datanotes.io.chars`.
|
|
186
|
+
|
|
187
|
+
### Errors and refusals
|
|
188
|
+
|
|
189
|
+
The engine tells failures and refusals apart:
|
|
190
|
+
|
|
191
|
+
* **A failure** is a bug, a disk error, a write that failed partway or a 5xx answer. Its span has status ERROR and an `exception` event with the type, message and stack.
|
|
192
|
+
* **A refusal** is a call the caller can fix: bad arguments, an unknown table, a broken CHECK. Its span keeps the status unset and sets `error.type`, such as `violation` or `TableNotFoundError`.
|
|
193
|
+
|
|
194
|
+
An empty answer to a `list_changes` poll sends no trace and no log line: services poll every few seconds.
|
|
195
|
+
|
|
196
|
+
## Follow one agent call across services
|
|
197
|
+
|
|
198
|
+
One agent call is one trace across the MCP server and the engine:
|
|
199
|
+
|
|
200
|
+
1. The MCP server makes a SERVER span, `tools/call <tool>`. It continues the client’s trace when the client sends `traceparent` in the call’s `_meta`.
|
|
201
|
+
2. Each request it sends to the engine is a CLIENT span, child of the tool’s span. The request carries that span as its `traceparent` header.
|
|
202
|
+
3. The engine’s SERVER span continues that trace, and its operation spans follow.
|
|
203
|
+
|
|
204
|
+
The tool span carries `mcp.method.name`, `gen_ai.operation.name` (`execute_tool`), `gen_ai.tool.name` and `mcp.session.id`. Find the agent call by its tool or session, open the trace, and you see where the time went in both processes.
|
|
205
|
+
|
|
206
|
+
The engine, the MCP server and the apps also log the same `session` and `tool` for each agent call. `session` is the first 16 characters of the MCP session ID.
|
|
207
|
+
|
|
208
|
+
## Find the slow operations
|
|
209
|
+
|
|
210
|
+
Your backend computes the latency of each span name from the traces. To find where the engine spends its time:
|
|
211
|
+
|
|
212
|
+
1. List the `engine.op <operation>` spans by duration, or open your backend’s database calls view. Leave out the spans with `datanotes.long_poll`: they wait on purpose.
|
|
213
|
+
2. Open a slow trace and compare its children. A long `engine.lock.acquire` means the call waited for another write. A long `engine.lock.hold` means the write itself was slow. A long `engine.schedule.wait` means a read waited for writes to end.
|
|
214
|
+
3. In a read, compare `engine.query.scan` with the rest. A slow scan with many `datanotes.cache.misses` reads many notes from disk. A slow scan with few misses works on a large table: narrow it with `where`, or split the table.
|
|
215
|
+
4. Look at the `datanotes.io.*_ms` attributes of the operation span. They show how much of its time went to the disk.
|
|
216
|
+
5. Set `telemetrySlowSpanMs` to log the slow calls as WARN records, and alert on them.
|
|
217
|
+
|
|
61
218
|
## Configure the MCP server and the apps
|
|
62
219
|
|
|
63
220
|
The MCP server and the apps read the same variables, and each has its own alternative:
|
|
64
221
|
|
|
65
|
-
* **`datanotes mcp`**: the `--otlp-endpoint your_endpoint` option
|
|
222
|
+
* **`datanotes mcp`**: the `--otlp-endpoint your_endpoint` option, for its logs and traces
|
|
66
223
|
* **Apps**: an `otlp` block in the app’s configuration, `{"endpoint": "http://127.0.0.1:4318", "headers": {}, "serviceName": "…"}`
|
|
67
224
|
|
|
68
|
-
##
|
|
225
|
+
## Continue a trace from your own program
|
|
226
|
+
|
|
227
|
+
Your program continues its trace in the engine when it sends the W3C `traceparent` header, and `tracestate` when it has one. `datanotes/client` does it for you with two options of `DatanotesClient`:
|
|
228
|
+
|
|
229
|
+
* **`trace`** is the trace to continue: a `traceparent` value, a context, or a function that returns the current one.
|
|
230
|
+
* **`tracer`** makes a CLIENT span for each request, child of `trace`, and sends that span as the engine’s parent.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import {
|
|
234
|
+
DatanotesClient,
|
|
235
|
+
OtlpTraceExporter,
|
|
236
|
+
Tracer,
|
|
237
|
+
otlpTracesFromEnv,
|
|
238
|
+
} from "datanotes/client";
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
const otlp = otlpTracesFromEnv(process.env, { serviceName: "my-app" });
|
|
242
|
+
const exporter = otlp ? new OtlpTraceExporter(otlp) : null;
|
|
243
|
+
const tracer = new Tracer({ export: (s) => exporter?.export(s) });
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
const run = tracer.startSpan("import films", { kind: "server" });
|
|
247
|
+
const client = new DatanotesClient({
|
|
248
|
+
url: "http://127.0.0.1:27150",
|
|
249
|
+
token: process.env.DATANOTES_TOKEN ?? "",
|
|
250
|
+
tracer,
|
|
251
|
+
trace: run.context,
|
|
252
|
+
});
|
|
253
|
+
await client.call("insert_row", {
|
|
254
|
+
table: "film",
|
|
255
|
+
values: { title: "Arrival" },
|
|
256
|
+
});
|
|
257
|
+
run.end();
|
|
258
|
+
await exporter?.close();
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Without a tracer, pass the incoming header as it is. A call can also name its own trace:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
await client.call("select_rows", { table: "film" }, undefined, {
|
|
265
|
+
trace: request.headers.traceparent,
|
|
266
|
+
});
|
|
267
|
+
```
|
|
69
268
|
|
|
70
|
-
|
|
269
|
+
Any HTTP client works too: send `traceparent: 00-<trace id>-<span id>-01` with the request.
|
|
71
270
|
|
|
72
271
|
## Send logs from your own program
|
|
73
272
|
|
|
74
|
-
`datanotes/client` exports the exporter that Datanotes uses, so your own programs can log the same way:
|
|
273
|
+
`datanotes/client` exports the log exporter that Datanotes uses, so your own programs can log the same way:
|
|
75
274
|
|
|
76
275
|
```ts
|
|
77
276
|
import { OtlpLogExporter, otlpFromEnv } from "datanotes/client";
|
|
@@ -83,4 +282,4 @@ log?.log("INFO", "imported films", { count: 8 });
|
|
|
83
282
|
await log?.close();
|
|
84
283
|
```
|
|
85
284
|
|
|
86
|
-
`otlpFromEnv` reads the variables above and returns `null` when you set no endpoint or turn the export off
|
|
285
|
+
`otlpFromEnv` reads the variables above and returns `null` when you set no endpoint or turn the export off. The program then logs nothing. Pass a span’s context as the fourth argument of `log` to link the record to that span. Call `close()` before the program exits to send the remaining records.
|