SensESP 3.4.1-alpha
Universal Signal K sensor toolkit ESP32
Loading...
Searching...
No Matches
log_buffer.cpp
Go to the documentation of this file.
2
3#include <esp_heap_caps.h>
4#include <esp_log.h>
5#include <esp_random.h>
6#include <esp_timer.h>
7
8#include <cstdio>
9#include <cstring>
10
11#ifndef USE_ESP_IDF_LOG
12#warning \
13 "USE_ESP_IDF_LOG is not defined: Arduino-ESP32 reroutes ESP_LOGx to " \
14 "log_printf, which bypasses esp_log_set_vprintf. The web log viewer will " \
15 "capture nothing. Build with -D USE_ESP_IDF_LOG."
16#endif
17
18namespace sensesp {
19
20LogBuffer* LogBuffer::instance_ = nullptr;
21
22LogBuffer::LogBuffer(size_t max_lines, uint32_t max_age_ms,
23 size_t max_line_length, size_t min_headroom_bytes)
24 : max_lines_(max_lines),
25 max_age_ms_(max_age_ms),
26 max_line_length_(max_line_length),
27 min_headroom_bytes_(min_headroom_bytes),
28 session_id_(0) {
29 mutex_ = xSemaphoreCreateMutexStatic(&mutex_buffer_);
30}
31
33 // Idempotent: a second call would set previous_vprintf_ to the trampoline
34 // itself (esp_log_set_vprintf returns the currently installed handler),
35 // turning the fallback paths into unbounded recursion. After the first
36 // install previous_vprintf_ is the (always non-null) prior handler.
37 if (previous_vprintf_ != nullptr) {
38 return;
39 }
40
41 // Draw the per-boot session id here rather than in the constructor: the hook
42 // is installed during setup(), past early static init, and mixing in the boot
43 // timer makes a repeated value across reboots unlikely even before the RF
44 // subsystem seeds the RNG. The client also falls back to a cursor reset.
45 session_id_ = esp_random() ^ static_cast<uint32_t>(esp_timer_get_time());
46
47 // Stand up the handoff queue and capture task before the hook goes live, so
48 // the first captured line already has somewhere to go. If the task can't be
49 // created, drop the queue too: a non-null queue with no drainer would fill
50 // after kLogCaptureQueueDepth lines and silently lose all capture for the
51 // rest of the uptime. Leaving capture_queue_ null makes the trampoline fall
52 // back to the chained handler (console preserved, capture disabled).
53 capture_queue_ = xQueueCreate(kLogCaptureQueueDepth, sizeof(LogQueueItem));
54 if (capture_queue_ != nullptr &&
55 xTaskCreate(&LogBuffer::capture_task, "LogCapture",
57 nullptr) != pdPASS) {
58 vQueueDelete(capture_queue_);
59 capture_queue_ = nullptr;
60 }
61
62 instance_ = this;
63 previous_vprintf_ = esp_log_set_vprintf(&LogBuffer::vprintf_trampoline);
64}
65
66void LogBuffer::enqueue_capture(LogQueueItem& item, int n) {
67 // Drop empty/error results; push_line would discard them anyway.
68 if (n <= 0) {
69 return;
70 }
71 item.length = (static_cast<size_t>(n) < sizeof(item.text))
72 ? static_cast<uint16_t>(n)
73 : static_cast<uint16_t>(sizeof(item.text) - 1);
74 item.timestamp_ms = esp_log_timestamp();
75 // Non-blocking: drop the line rather than stall the logger if the capture
76 // task is behind.
77 xQueueSend(capture_queue_, &item, 0);
78}
79
80void LogBuffer::capture_task(void* arg) {
81 auto* self = static_cast<LogBuffer*>(arg);
82 LogQueueItem item;
83 while (true) {
84 if (xQueueReceive(self->capture_queue_, &item, portMAX_DELAY) == pdTRUE) {
85 // The heap-allocating work (ANSI strip, std::string, deque) happens here,
86 // on this task's stack, not the logging task's.
87 self->push_line(item.text, item.length, item.timestamp_ms);
88 }
89 }
90}
91
92int LogBuffer::vprintf_trampoline(const char* format, va_list args) {
93 LogBuffer* inst = instance_;
94
95#if !defined(ESP_LOG_VERSION) || ESP_LOG_VERSION == 1
96 // ESP-IDF log V1: the hook is called once per line with the complete format
97 // string and va_list, so a single vsnprintf reproduces the exact console
98 // bytes. We format ONCE on the caller stack, emit the bytes ourselves, and
99 // hand the same buffer to the capture task -- avoiding the chained handler's
100 // second full format pass (newlib vfprintf), which is the dominant stack cost
101 // and what overflows small logging tasks.
102 //
103 // ISR / pre-install context: stdio and the capture queue are unsafe, so fall
104 // back to the chained handler (a full vfprintf). Rare path.
105 if (inst == nullptr || inst->capture_queue_ == nullptr ||
106 xPortInIsrContext()) {
107 if (inst != nullptr && inst->previous_vprintf_ != nullptr) {
108 return inst->previous_vprintf_(format, args);
109 }
110 return 0;
111 }
112
113 LogQueueItem item;
114 va_list args_copy;
115 va_copy(args_copy, args);
116 int n = vsnprintf(item.text, sizeof(item.text), format, args);
117 if (n < 0) {
118 va_end(args_copy);
119 return n;
120 }
121
122 int ret = n;
123 if (static_cast<size_t>(n) >= sizeof(item.text)) {
124 // The line is longer than our buffer. Self-emitting the truncated copy
125 // would drop the trailing newline and run console lines together, so emit
126 // the full line via the chained handler -- a second format pass. This
127 // re-incurs the caller-stack cost the lean path avoids, so it relies on
128 // over-length lines coming from roomy-stack loggers; the small-stack tasks
129 // this protects log short lines and never reach here.
130 if (inst->previous_vprintf_ != nullptr) {
131 ret = inst->previous_vprintf_(format, args_copy);
132 }
133 } else {
134 // Fits: format once, emit the bytes ourselves through stdout (not a fixed
135 // peripheral) so output follows the active console -- UART and
136 // USB-CDC/JTAG alike. newlib FILE locking serializes concurrent writers,
137 // as it did for the chained vfprintf.
138 fwrite(item.text, 1, static_cast<size_t>(n), stdout);
139 }
140 va_end(args_copy);
141
142 // Hand the (possibly truncated) line to the capture task; the heap work
143 // runs on its stack, not the caller's.
144 inst->enqueue_capture(item, n);
145 return ret;
146#else
147 // ESP-IDF log V2 calls the hook multiple times per line with fragments, so a
148 // single format-and-emit would mangle output. Fall back to chaining the
149 // previous handler for console output and a second format for capture.
150 int ret = 0;
151 if (inst != nullptr && inst->previous_vprintf_ != nullptr) {
152 va_list copy;
153 va_copy(copy, args);
154 ret = inst->previous_vprintf_(format, copy);
155 va_end(copy);
156 }
157 if (inst != nullptr && inst->capture_queue_ != nullptr &&
158 !xPortInIsrContext()) {
159 LogQueueItem item;
160 va_list copy;
161 va_copy(copy, args);
162 int n = vsnprintf(item.text, sizeof(item.text), format, copy);
163 va_end(copy);
164 inst->enqueue_capture(item, n);
165 }
166 return ret;
167#endif
168}
169
170namespace {
171// Copy text with ANSI/VT100 escape sequences removed. ESP_LOG wraps each line
172// in color codes when CONFIG_LOG_COLORS is enabled; the raw ESC byte (0x1b) is
173// a control character that ArduinoJson does not escape, so it would otherwise
174// reach the /api/log response unescaped and make the JSON unparseable in the
175// browser. A CSI sequence is ESC '[' then parameter/intermediate bytes up to a
176// final byte in 0x40-0x7e; a lone ESC is simply dropped.
177std::string strip_ansi(const char* text, size_t length) {
178 std::string out;
179 out.reserve(length);
180 size_t i = 0;
181 while (i < length) {
182 if (text[i] == '\x1b') {
183 i++; // drop ESC
184 if (i < length && text[i] == '[') {
185 i++; // drop '['
186 while (i < length) {
187 unsigned char c = static_cast<unsigned char>(text[i]);
188 i++;
189 if (c >= 0x40 && c <= 0x7e) {
190 break; // final byte, end of sequence
191 }
192 }
193 }
194 continue;
195 }
196 out.push_back(text[i]);
197 i++;
198 }
199 return out;
200}
201} // namespace
202
203void LogBuffer::push_line(const char* text, size_t length, uint32_t now_ms) {
204 // Strip trailing CR/LF so each record is one display line.
205 while (length > 0 && (text[length - 1] == '\n' || text[length - 1] == '\r')) {
206 length--;
207 }
208 if (length == 0) {
209 return;
210 }
211
212 // Never let log capture be the allocation that exhausts the heap. strip_ansi()
213 // and the records_ deque below both allocate; with C++ exceptions disabled a
214 // failed allocation throws std::bad_alloc, which routes to abort() and reboots
215 // the device -- so under memory pressure the logger would crash the device
216 // while capturing the very errors that report the pressure. Drop the line
217 // instead, mirroring the queue-full drop policy in enqueue_capture().
218 if (heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) < min_headroom_bytes_) {
219 return;
220 }
221
222 // Remove ANSI color escapes so the buffered copy served over /api/log is
223 // plain, JSON-safe text. The console output above keeps its colors.
224 std::string line = strip_ansi(text, length);
225 if (line.empty()) {
226 return;
227 }
228 if (line.size() > max_line_length_) {
229 line.resize(max_line_length_);
230 }
231
232 xSemaphoreTake(mutex_, portMAX_DELAY);
233 prune_locked(now_ms);
234 records_.push_back({next_seq_, now_ms, std::move(line)});
235 next_seq_++;
236 // Enforce the count cap after inserting the new record.
237 while (records_.size() > max_lines_) {
238 records_.pop_front();
239 }
240 xSemaphoreGive(mutex_);
241}
242
243void LogBuffer::prune_locked(uint32_t now_ms) {
244 while (!records_.empty()) {
245 const LogRecord& front = records_.front();
246 if (now_ms >= front.timestamp_ms &&
247 now_ms - front.timestamp_ms > max_age_ms_) {
248 records_.pop_front();
249 } else {
250 break;
251 }
252 }
253}
254
255LogSnapshot LogBuffer::snapshot_since(uint32_t since, bool has_since,
256 uint32_t now_ms) {
257 LogSnapshot snapshot;
258 snapshot.session_id = session_id_;
259 snapshot.gap = false;
260
261 xSemaphoreTake(mutex_, portMAX_DELAY);
262 prune_locked(now_ms);
263 snapshot.next = next_seq_;
264
265 if (!has_since) {
266 // Initial load: return the whole retained buffer, never a gap.
267 for (const auto& record : records_) {
268 snapshot.lines.push_back(record.text);
269 }
270 } else {
271 if (records_.empty()) {
272 // Lines existed between the client's cursor and now but were all
273 // evicted. (since >= next_seq_ is the caught-up or post-reboot case.)
274 snapshot.gap = since < next_seq_;
275 } else if (records_.front().seq > since) {
276 // The line the client expected next was evicted before it was read.
277 snapshot.gap = true;
278 }
279 for (const auto& record : records_) {
280 if (record.seq >= since) {
281 snapshot.lines.push_back(record.text);
282 }
283 }
284 }
285 xSemaphoreGive(mutex_);
286
287 return snapshot;
288}
289
290LogSnapshot LogBuffer::snapshot_since(uint32_t since, bool has_since) {
291 return snapshot_since(since, has_since, esp_log_timestamp());
292}
293
295 xSemaphoreTake(mutex_, portMAX_DELAY);
296 size_t count = records_.size();
297 xSemaphoreGive(mutex_);
298 return count;
299}
300
301} // namespace sensesp
void push_line(const char *text, size_t length, uint32_t now_ms)
Append a pre-formatted log line to the buffer.
LogBuffer(size_t max_lines=100, uint32_t max_age_ms=2000, size_t max_line_length=kLogCaptureLineMax, size_t min_headroom_bytes=kLogCaptureMinHeadroomBytes)
LogSnapshot snapshot_since(uint32_t since, bool has_since, uint32_t now_ms)
Return retained lines newer than since.
void install()
Install the chained vprintf hook. Call once, early in startup.
size_t size()
Test/inspection helper: current number of retained records.
constexpr size_t kLogCaptureQueueDepth
Definition log_buffer.h:30
constexpr UBaseType_t kLogCaptureTaskPriority
Definition log_buffer.h:37
constexpr uint32_t kLogCaptureTaskStackSize
Definition log_buffer.h:36
SKWSClient * self
char text[kLogCaptureLineMax]
Definition log_buffer.h:54
A single captured log line.
Definition log_buffer.h:20
uint32_t timestamp_ms
Definition log_buffer.h:22
Result of a snapshot_since() query, ready to serialize for the web UI.
Definition log_buffer.h:66
std::vector< std::string > lines
Definition log_buffer.h:70