github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/LogStore.ts (about) 1 // Client-side log store, which helps client side rendering and filtering of logs. 2 // 3 // Loosely adapted from the data structures in 4 // pkg/model/logstore/logstore.go 5 // but with better support for incremental updates and rendering. 6 7 import React, { useContext } from "react" 8 import { isBuildSpanId } from "./logs" 9 import { LogLevel, LogLine, LogPatchSet } from "./types" 10 11 // Firestore doesn't properly handle maps with keys equal to the empty string, so 12 // we normalize all empty span ids to '_' client-side. 13 const defaultSpanId = "_" 14 const fieldNameProgressId = "progressID" 15 16 const defaultMaxLogLength = 2 * 1000 * 1000 17 18 // Index all warnings and errors by span. 19 export type LogAlert = { 20 lineIndex: number 21 level: LogLevel 22 } 23 24 // LogStore implements LogAlertIndex, a narrower interface for fetching 25 // the alerts for a particular span. 26 // 27 // Consumers of LogAlertIndex shouldn't assume it's a LogStore. In the future, 28 // we may break them up into separate objects. 29 export interface LogAlertIndex { 30 alertsForSpanId(spanId: string): LogAlert[] 31 } 32 33 type LogSpan = { 34 spanId: string 35 manifestName: string 36 firstLineIndex: number 37 lastLineIndex: number 38 alerts: LogAlert[] 39 } 40 41 type LogWarning = { 42 anchorIndex: number 43 spanId: string 44 text: string 45 } 46 47 class StoredLine { 48 spanId: string 49 time: string 50 text: string 51 level: string 52 anchor: boolean 53 fields: { [key: string]: string } | null 54 55 constructor(seg: Proto.webviewLogSegment) { 56 this.spanId = seg.spanId || defaultSpanId 57 this.time = seg.time ?? "" 58 this.text = seg.text ?? "" 59 this.level = seg.level ?? "INFO" 60 this.anchor = seg.anchor ?? false 61 this.fields = (seg.fields as { [key: string]: string }) ?? null 62 } 63 64 field(key: string) { 65 if (!this.fields) { 66 return "" 67 } 68 return this.fields[key] ?? "" 69 } 70 71 isComplete() { 72 return this.text[this.text.length - 1] === "\n" 73 } 74 75 canContinueLine(other: StoredLine) { 76 return this.level === other.level && this.spanId === other.spanId 77 } 78 } 79 80 export enum LogUpdateAction { 81 append, 82 truncate, 83 } 84 85 export interface LogUpdateEvent { 86 action: LogUpdateAction 87 } 88 89 type callback = (e: LogUpdateEvent) => void 90 91 class LogStore implements LogAlertIndex { 92 // Track which segments we've received from the server. 93 checkpoint: number 94 95 spans: { [key: string]: LogSpan } 96 97 // These are held in-memory so we can send them on snapshot, and are 98 // also used to help with incremental log rendering. 99 segments: Proto.webviewLogSegment[] 100 101 // A map of segment indices to the line indices that they rendered. 102 segmentToLine: number[] 103 104 // As segments are appended, we fold them into our internal line-by-line model 105 // for rendering. 106 lines: StoredLine[] 107 108 // A cache of the react data model 109 lineCache: { [key: number]: LogLine } 110 111 updateCallbacks: callback[] 112 113 // Track log length, for truncation. 114 logLength: number = 0 115 maxLogLength: number 116 117 constructor() { 118 this.spans = {} 119 this.segments = [] 120 this.segmentToLine = [] 121 this.lines = [] 122 this.checkpoint = 0 123 this.lineCache = {} 124 this.updateCallbacks = [] 125 this.maxLogLength = defaultMaxLogLength 126 } 127 128 addUpdateListener(c: callback) { 129 if (!this.updateCallbacks.includes(c)) { 130 this.updateCallbacks.push(c) 131 } 132 } 133 134 removeUpdateListener(c: callback) { 135 this.updateCallbacks = this.updateCallbacks.filter((item) => item !== c) 136 } 137 138 hasLinesForSpan(spanId: string): boolean { 139 const span = this.spans[spanId] 140 return span && span.firstLineIndex !== -1 141 } 142 143 toLogList(maxSize: number | null | undefined): Proto.webviewLogList { 144 let spans = {} as { [key: string]: Proto.webviewLogSpan } 145 146 let size = 0 147 const segments = [] as Proto.webviewLogSegment[] 148 for (let i = this.segments.length - 1; i >= 0; i--) { 149 let segment = this.segments[i] 150 size += segment.text?.length || 0 151 if (maxSize && size > maxSize) { 152 break 153 } 154 155 let spanId = segment.spanId 156 if (spanId && !spans[spanId]) { 157 spans[spanId] = { manifestName: this.spans[spanId].manifestName } 158 } 159 160 segments.push({ 161 spanId: spanId, 162 time: segment.time, 163 text: segment.text, 164 level: segment.level, 165 fields: segment.fields, 166 }) 167 } 168 169 // caller expects segments in chronological order 170 // (iteration here was done backwards for truncation) 171 segments.reverse() 172 173 return { 174 spans: spans, 175 segments: segments, 176 } 177 } 178 179 append(logList: Proto.webviewLogList) { 180 let newSpans = logList.spans as { [key: string]: Proto.webviewLogSpan } 181 let newSegments = logList.segments ?? [] 182 let fromCheckpoint = logList.fromCheckpoint ?? 0 183 let toCheckpoint = logList.toCheckpoint ?? 0 184 if (fromCheckpoint < 0) { 185 return 186 } 187 188 if (fromCheckpoint < this.checkpoint) { 189 // The server is re-sending some logs we already have, so slice them off. 190 let deleteCount = this.checkpoint - fromCheckpoint 191 newSegments = newSegments.slice(deleteCount) 192 } 193 194 if (toCheckpoint > this.checkpoint) { 195 this.checkpoint = toCheckpoint 196 } 197 198 for (let key in newSpans) { 199 let spanId = key || defaultSpanId 200 let existingSpan = this.spans[spanId] 201 if (!existingSpan) { 202 this.spans[spanId] = { 203 spanId: spanId, 204 manifestName: newSpans[key].manifestName ?? "", 205 firstLineIndex: -1, 206 lastLineIndex: -1, 207 alerts: [], 208 } 209 } 210 } 211 212 newSegments.forEach((segment) => this.addSegment(segment)) 213 214 this.invokeUpdateCallbacks({ 215 action: LogUpdateAction.append, 216 }) 217 218 this.ensureMaxLength() 219 } 220 221 // Returns a list of all error and warning log lines in this span, 222 // and their line index. Consumers must not mutate the list. 223 alertsForSpanId(spanId: string): LogAlert[] { 224 let span = this.spans[spanId] 225 if (!span) { 226 return [] 227 } 228 return span.alerts 229 } 230 231 private invokeUpdateCallbacks(e: LogUpdateEvent) { 232 window.requestAnimationFrame(() => { 233 // Make sure an exception in one callback doesn't affect the rest. 234 try { 235 this.updateCallbacks.forEach((c) => c(e)) 236 } catch (e) { 237 window.requestAnimationFrame(() => { 238 throw e 239 }) 240 } 241 }) 242 } 243 244 private addSegment(newSegment: Proto.webviewLogSegment) { 245 // workaround firestore bug. see comments on defaultSpanId. 246 newSegment.spanId = newSegment.spanId || defaultSpanId 247 this.segments.push(newSegment) 248 this.logLength += newSegment.text?.length || 0 249 250 let candidate = new StoredLine(newSegment) 251 let spanId = candidate.spanId 252 let span = this.spans[spanId] 253 if (!span) { 254 // If we don't have the span for this log, we can't meaningfully print it, 255 // so just drop it. This means that there's a bug on the server, and 256 // the best the client can do is fail gracefully. 257 this.segmentToLine.push(-1) 258 return 259 } 260 let isStartingNewLine = false 261 if (span.lastLineIndex === -1) { 262 isStartingNewLine = true 263 this.segmentToLine.push(this.lines.length) 264 } else { 265 let line = this.lines[span.lastLineIndex] 266 let overwriteIndex = this.maybeOverwriteLine(candidate, span) 267 if (overwriteIndex !== -1) { 268 this.segmentToLine.push(overwriteIndex) 269 return 270 } else if (line.isComplete() || !line.canContinueLine(candidate)) { 271 isStartingNewLine = true 272 this.segmentToLine.push(this.lines.length) 273 } else { 274 line.text += candidate.text 275 delete this.lineCache[span.lastLineIndex] 276 this.segmentToLine.push(span.lastLineIndex) 277 return 278 } 279 } 280 281 if (span.firstLineIndex === -1) { 282 span.firstLineIndex = this.lines.length 283 } 284 285 if (isStartingNewLine) { 286 let lineIndex = this.lines.length 287 span.lastLineIndex = lineIndex 288 this.lines.push(candidate) 289 290 // If this starts a warning or error, index it now. 291 let level = newSegment.level 292 if ( 293 newSegment.anchor && 294 (level === LogLevel.WARN || level === LogLevel.ERROR) 295 ) { 296 span.alerts.push({ level, lineIndex }) 297 } 298 } 299 } 300 301 // Remove spans from the LogStore, triggering a full rebuild of the line cache. 302 removeSpans(spanIds: string[]) { 303 if (spanIds.length === 0) { 304 return 305 } 306 307 this.logLength = 0 308 this.lines = [] 309 this.lineCache = [] 310 this.segmentToLine = [] 311 const spansToDelete = new Set(spanIds) 312 if (spansToDelete.has("")) { 313 spansToDelete.delete("") 314 spansToDelete.add(defaultSpanId) 315 } 316 317 for (const span of Object.values(this.spans)) { 318 const spanId = span.spanId 319 if (spansToDelete.has(spanId)) { 320 delete this.spans[spanId] 321 } else { 322 span.firstLineIndex = -1 323 span.lastLineIndex = -1 324 span.alerts = [] 325 } 326 } 327 328 const currentSegments = this.segments 329 this.segments = [] 330 for (const segment of currentSegments) { 331 const spanId = segment.spanId 332 if (spanId && !spansToDelete.has(spanId)) { 333 // re-add any non-deleted segments 334 this.addSegment(segment) 335 } 336 } 337 338 this.invokeUpdateCallbacks({ 339 action: LogUpdateAction.truncate, 340 }) 341 } 342 343 // If this line has a progress id, see if we can overwrite a previous line. 344 // Return the index of the line we were able to overwrite, or -1 otherwise. 345 private maybeOverwriteLine(candidate: StoredLine, span: LogSpan): number { 346 let progressId = candidate.field(fieldNameProgressId) 347 if (!progressId) { 348 return -1 349 } 350 351 // Iterate backwards and figure out which line to overwrite. 352 for (let i = span.lastLineIndex; i >= span.firstLineIndex; i--) { 353 let cur = this.lines[i] 354 if (cur.spanId !== candidate.spanId) { 355 // skip lines from other spans 356 // TODO(nick): maybe we should track if spans are interleaved, and rearrange the 357 // lines to make more sense? 358 continue 359 } 360 361 // If we're outside the "progress" zone, we couldn't find it. 362 let curProgressId = cur.field(fieldNameProgressId) 363 if (!curProgressId) { 364 return -1 365 } 366 367 if (progressId !== curProgressId) { 368 continue 369 } 370 371 cur.text = candidate.text 372 delete this.lineCache[i] 373 return i 374 } 375 return -1 376 } 377 378 allLog(): LogLine[] { 379 return this.logHelper(this.spans, 0).lines 380 } 381 382 allLogPatchSet(checkpoint: number): LogPatchSet { 383 return this.logHelper(this.spans, checkpoint) 384 } 385 386 spanLog(spanIds: string[]): LogLine[] { 387 let spans: { [key: string]: LogSpan } = {} 388 spanIds.forEach((spanId) => { 389 spanId = spanId ? spanId : defaultSpanId 390 let span = this.spans[spanId] 391 if (span) { 392 spans[spanId] = span 393 } 394 }) 395 396 return this.logHelper(spans, 0).lines 397 } 398 399 allSpans(): { [key: string]: LogSpan } { 400 const result: { [key: string]: LogSpan } = {} 401 for (let spanId in this.spans) { 402 result[spanId] = this.spans[spanId] 403 } 404 return result 405 } 406 407 spansForManifest(mn: string): { [key: string]: LogSpan } { 408 let result: { [key: string]: LogSpan } = {} 409 for (let spanId in this.spans) { 410 let span = this.spans[spanId] 411 if (span.manifestName === mn) { 412 result[spanId] = span 413 } 414 } 415 return result 416 } 417 418 getOrderedBuildSpanIds(spanId: string): string[] { 419 let startSpan = this.spans[spanId] 420 if (!startSpan) { 421 return [] 422 } 423 424 let manifestName = startSpan.manifestName 425 const spanIds: string[] = [] 426 for (let key in this.spans) { 427 if (!isBuildSpanId(key)) { 428 continue 429 } 430 431 let span = this.spans[key] 432 if (span.manifestName !== manifestName) { 433 continue 434 } 435 436 spanIds.push(key) 437 } 438 439 return this.sortedSpanIds(spanIds) 440 } 441 442 getOrderedBuildSpans(spanId: string): LogSpan[] { 443 return this.getOrderedBuildSpanIds(spanId).map( 444 (spanId) => this.spans[spanId] 445 ) 446 } 447 448 private sortedSpanIds(spanIds: string[]): string[] { 449 return spanIds.sort((a, b) => { 450 return this.spans[a].firstLineIndex - this.spans[b].firstLineIndex 451 }) 452 } 453 454 // Given a build span in the current manifest, find the next build span. 455 nextBuildSpan(spanId: string): LogSpan | null { 456 let spanIds = this.getOrderedBuildSpanIds(spanId) 457 let currentIndex = spanIds.indexOf(spanId) 458 if (currentIndex === -1 || currentIndex === spanIds.length - 1) { 459 return null 460 } 461 return this.spans[spanIds[currentIndex + 1]] 462 } 463 464 // Find all the logs "caused" by a particular build. 465 // 466 // Eventually, we should add causality links between spans to the 467 // data model itself! c.f., Links in open-tracing 468 // https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/api-tracing.md#add-links 469 // But for now, we just hack some spans together based on their manifest name 470 // and when they showed up. 471 traceLog(spanId: string): LogLine[] { 472 // Currently, we only support tracing of build logs. 473 if (!isBuildSpanId(spanId)) { 474 return [] 475 } 476 477 let startSpan = this.spans[spanId] 478 let spans: { [key: string]: LogSpan } = {} 479 spans[spanId] = startSpan 480 481 let nextBuildSpan = this.nextBuildSpan(spanId) 482 483 // Grab all the spans that start between this span and the next build. 484 // 485 // TODO(nick): This currently skips any events that happen 486 // because they're part of an "events" span where the causality 487 // is uncertain. We should be more intelligent about sucking in events. 488 for (let key in this.spans) { 489 let candidate = this.spans[key] 490 if (candidate.manifestName !== startSpan.manifestName) { 491 continue 492 } 493 494 if ( 495 candidate.firstLineIndex > startSpan.firstLineIndex && 496 (!nextBuildSpan || 497 candidate.firstLineIndex < nextBuildSpan.firstLineIndex) 498 ) { 499 spans[key] = candidate 500 } 501 } 502 503 return this.logHelper(spans, 0).lines 504 } 505 506 manifestLog(mn: string): LogLine[] { 507 let spans = this.spansForManifest(mn) 508 return this.logHelper(spans, 0).lines 509 } 510 511 manifestLogPatchSet(mn: string, checkpoint: number): LogPatchSet { 512 let spans = this.spansForManifest(mn) 513 return this.logHelper(spans, checkpoint) 514 } 515 516 starredLogPatchSet(stars: string[], checkpoint: number): LogPatchSet { 517 let result: { [key: string]: LogSpan } = {} 518 for (let spanId in this.spans) { 519 let span = this.spans[spanId] 520 if (stars.includes(span.manifestName)) { 521 result[spanId] = span 522 } 523 } 524 return this.logHelper(result, checkpoint) 525 } 526 527 // Return all the logs for the given options. 528 // 529 // spansToLog: Filtering by an arbitrary set of spans. 530 // checkpoint: Continuation from an earlier checkpoint, only returning lines updated 531 // since that checkpoint. Pass 0 to return all logs. 532 logHelper( 533 spansToLog: { [key: string]: LogSpan }, 534 checkpoint: number 535 ): LogPatchSet { 536 let result: LogLine[] = [] 537 538 // We want to print the log line-by-line, but we don't actually store the logs 539 // line-by-line. We store them as segments. 540 // 541 // This means we need to: 542 // 1) At segment x, 543 // 2) If x starts a new line, print it, then run ahead to print the rest of the line 544 // until the entire line is consumed. 545 // 3) If x does not start a new line, skip it, because we assume it was handled 546 // in a previous line. 547 // 548 // This can have some O(n^2) perf characteristics in the worst case, but 549 // for normal inputs should be fine. 550 let startIndex = 0 551 let lastIndex = this.lines.length - 1 552 let isFilteredLog = 553 Object.keys(spansToLog).length !== Object.keys(this.spans).length 554 if (isFilteredLog) { 555 let earliestStartIndex = -1 556 let latestEndIndex = -1 557 for (let spanId in spansToLog) { 558 let span = spansToLog[spanId] 559 if ( 560 earliestStartIndex === -1 || 561 (span.firstLineIndex !== -1 && 562 span.firstLineIndex < earliestStartIndex) 563 ) { 564 earliestStartIndex = span.firstLineIndex 565 } 566 if ( 567 latestEndIndex === -1 || 568 (span.lastLineIndex !== -1 && span.lastLineIndex > latestEndIndex) 569 ) { 570 latestEndIndex = span.lastLineIndex 571 } 572 } 573 574 if (earliestStartIndex === -1) { 575 return { lines: [], checkpoint: checkpoint } 576 } 577 578 startIndex = earliestStartIndex 579 lastIndex = latestEndIndex 580 } 581 582 // Only look at segments that have come in since the last checkpoint. 583 let incremental = checkpoint > 0 584 let linesToLog: { [key: number]: boolean } = {} 585 if (incremental) { 586 let earliestStartIndex = -1 587 for (let i = checkpoint; i < this.segments.length; i++) { 588 let segment = this.segments[i] 589 let span = spansToLog[segment.spanId || defaultSpanId] 590 if (!span) { 591 continue 592 } 593 594 let lineIndex = this.segmentToLine[i] 595 if (earliestStartIndex === -1 || lineIndex < earliestStartIndex) { 596 earliestStartIndex = lineIndex 597 } 598 linesToLog[lineIndex] = true 599 } 600 601 if (earliestStartIndex !== -1 && earliestStartIndex > startIndex) { 602 startIndex = earliestStartIndex 603 } 604 } 605 606 for (let i = startIndex; i <= lastIndex; i++) { 607 let storedLine = this.lines[i] 608 let spanId = storedLine.spanId 609 let span = spansToLog[spanId] 610 if (!span) { 611 continue 612 } 613 614 if (incremental && !linesToLog[i]) { 615 continue 616 } 617 618 let line = this.lineCache[i] 619 if (!line) { 620 let text = storedLine.text 621 // strip off the newline 622 if (text[text.length - 1] === "\n") { 623 text = text.substring(0, text.length - 1) 624 } 625 line = { 626 text: text, 627 level: storedLine.level, 628 manifestName: span.manifestName, 629 buildEvent: storedLine.fields?.buildEvent, 630 spanId: spanId, 631 storedLineIndex: i, 632 } 633 634 this.lineCache[i] = line 635 } 636 637 result.push(line) 638 } 639 640 return { 641 lines: result, 642 checkpoint: this.segments.length, 643 } 644 } 645 646 // After a log hits its limit, we need to truncate it to keep it small 647 // we do this by cutting a big chunk at a time, so that we have rarer, larger changes, instead of 648 // a small change every time new data is written to the log 649 // https://github.com/tilt-dev/tilt/issues/1935#issuecomment-531390353 650 logTruncationTarget(): number { 651 return this.maxLogLength / 2 652 } 653 654 ensureMaxLength() { 655 if (this.logLength <= this.maxLogLength) { 656 return 657 } 658 659 // First, count the number of bytes in each manifest. 660 let manifestWeights: { 661 [key: string]: { name: string; byteCount: number; start: string } 662 } = {} 663 664 for (let segment of this.segments) { 665 let span = this.spans[segment.spanId || defaultSpanId] 666 if (span) { 667 let name = span.manifestName || "" 668 let weight = manifestWeights[name] 669 if (!weight) { 670 weight = { name, byteCount: 0, start: segment.time || "" } 671 manifestWeights[name] = weight 672 } 673 weight.byteCount += segment.text?.length || 0 674 } 675 } 676 677 // Next, repeatedly cut the longest manifest in half until 678 // we've reached the target number of bytes to cut. 679 let leftToCut = this.logLength - this.logTruncationTarget() 680 while (leftToCut > 0) { 681 let mn = this.heaviestManifestName(manifestWeights) 682 let amountToCut = Math.ceil(manifestWeights[mn].byteCount / 2) 683 if (amountToCut > leftToCut) { 684 amountToCut = leftToCut 685 } 686 leftToCut -= amountToCut 687 manifestWeights[mn].byteCount -= amountToCut 688 } 689 690 // Lastly, go through all the segments, and truncate the manifests 691 // where we said we would. 692 let newSegments = [] 693 let trimmedSegmentCount = 0 694 for (let i = this.segments.length - 1; i >= 0; i--) { 695 let segment = this.segments[i] 696 let span = this.spans[segment.spanId || defaultSpanId] 697 let mn = span?.manifestName || "" 698 let len = segment.text?.length || 0 699 manifestWeights[mn].byteCount -= len 700 if (manifestWeights[mn].byteCount < 0) { 701 trimmedSegmentCount++ 702 continue 703 } 704 705 newSegments.push(segment) 706 } 707 708 newSegments.reverse() 709 710 // Reset the state of the logstore. 711 this.logLength = 0 712 this.lines = [] 713 this.lineCache = [] 714 this.segmentToLine = [] 715 716 for (const span of Object.values(this.spans)) { 717 span.firstLineIndex = -1 718 span.lastLineIndex = -1 719 span.alerts = [] 720 } 721 722 this.segments = [] 723 for (const segment of newSegments) { 724 this.addSegment(segment) 725 } 726 727 this.invokeUpdateCallbacks({ 728 action: LogUpdateAction.truncate, 729 }) 730 } 731 732 // There are 3 types of logs we need to consider: 733 // 1) Jobs that print short, critical information at the start. 734 // 2) Jobs that print lots of health checks continuously. 735 // 3) Jobs that print recent test results. 736 // 737 // Truncating purely on recency would be bad for (1). 738 // Truncating purely on length would be bad for (3). 739 // 740 // So we weight based on both recency and length. 741 heaviestManifestName(manifestWeights: { 742 [key: string]: { name: string; byteCount: number; start: string } 743 }): string { 744 // Sort manifests by most recent first. 745 let manifestsByTime = Object.values(manifestWeights).sort((a, b) => { 746 if (a.start != b.start) { 747 return a.start < b.start ? 1 : -1 748 } 749 if (a.name != b.name) { 750 return a.name < b.name ? 1 : -1 751 } 752 return 0 753 }) 754 755 let heaviest = "" 756 let heaviestValue = -1 757 for (let i = 0; i < manifestsByTime.length; i++) { 758 // We compute: weightValue = order * byteCount where the manifest with 759 // most recent logs has order 1, the next one has order 2, and so on. 760 // 761 // This helps ensures older logs get truncated first. 762 let order = i + 1 763 let value = order * manifestsByTime[i].byteCount 764 if (value > heaviestValue) { 765 heaviest = manifestsByTime[i].name 766 heaviestValue = value 767 } 768 } 769 return heaviest 770 } 771 } 772 773 export default LogStore 774 775 const logStoreContext = React.createContext<LogStore>(new LogStore()) 776 777 export function useLogStore(): LogStore { 778 return useContext(logStoreContext) 779 } 780 781 // LogAlertIndex provides access to warnings/errors without the rest of the 782 // LogStore. 783 // 784 // Consumers of LogAlertIndex shouldn't assume it's a LogStore. In the future, 785 // we may break them up into separate objects. 786 export function useLogAlertIndex(): LogAlertIndex { 787 return useContext(logStoreContext) 788 } 789 790 export let LogStoreProvider = logStoreContext.Provider