Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,38 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen
}

func search(arguments: [String: Any]) throws -> HybridSearchResponse {
try queue.sync {
try startLocked()
do {
return try send(arguments: arguments)
} catch {
if Self.shouldRestartHelper(after: error) {
stopLocked()
let profileQueryID = (arguments["_profile_query_id"] as? String)
?? (SearchProfileLogger.isEnabled ? SearchProfileLogger.newQueryID() : nil)
let profileStartedAt = SearchProfileLogger.now()
SearchProfileLogger.log(scope: "search.brainbar", step: "helper_rpc_start", queryID: profileQueryID)
do {
let response = try queue.sync {
try startLocked()
do {
return try send(arguments: arguments)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate generated query_id to helper payload

When BRAINLAYER_SEARCH_PROFILE=1 and the caller omits _profile_query_id, this method generates a profileQueryID for helper_rpc_* logs but sends the original arguments unchanged. The Python helper then generates a different ID (HybridSearchHelper._search), so a single request gets split across multiple query_ids and cannot be reliably correlated end-to-end in profiling logs. This affects any direct HybridSearchHelperClient.search(arguments:) caller that doesn’t pre-populate _profile_query_id.

Useful? React with 👍 / 👎.

} catch {
if Self.shouldRestartHelper(after: error) {
stopLocked()
}
throw error
}
throw error
}
SearchProfileLogger.log(
scope: "search.brainbar",
step: "helper_rpc_done",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: profileStartedAt)
)
return response
} catch {
SearchProfileLogger.log(
scope: "search.brainbar",
step: "helper_rpc_done",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: profileStartedAt),
fields: ["error": String(describing: error)]
)
throw error
}
}

Expand Down
18 changes: 16 additions & 2 deletions brain-bar/Sources/BrainBar/MCPRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ final class MCPRouter: @unchecked Sendable {
// MARK: - Tool Handlers

private func handleBrainSearch(_ args: [String: Any]) throws -> ToolOutput {
let profileStartedAt = SearchProfileLogger.now()
let profileQueryID = (args["_profile_query_id"] as? String)
?? (SearchProfileLogger.isEnabled ? SearchProfileLogger.newQueryID() : nil)
guard let query = args["query"] as? String else {
throw ToolError.missingParameter("query")
}
Expand All @@ -262,6 +265,12 @@ final class MCPRouter: @unchecked Sendable {
guard let db = database else {
throw ToolError.noDatabase
}
SearchProfileLogger.log(
scope: "search.brainbar",
step: "router_dispatch",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: profileStartedAt)
)

func localKGSection() -> String {
let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil
Expand Down Expand Up @@ -309,7 +318,8 @@ final class MCPRouter: @unchecked Sendable {
source: source,
tag: tag,
importanceMin: importanceMin,
detail: args["detail"] as? String
detail: args["detail"] as? String,
profileQueryID: profileQueryID
))
textSection = response.text
metadata = sanitizedHybridMetadata(response.metadata)
Expand Down Expand Up @@ -350,7 +360,8 @@ final class MCPRouter: @unchecked Sendable {
source: String?,
tag: String?,
importanceMin: Double?,
detail: String?
detail: String?,
profileQueryID: String?
) -> [String: Any] {
var arguments: [String: Any] = [
"query": query,
Expand All @@ -367,6 +378,9 @@ final class MCPRouter: @unchecked Sendable {
if let importanceMin {
arguments["importance_min"] = importanceMin
}
if let profileQueryID {
arguments["_profile_query_id"] = profileQueryID
}
return arguments
}

Expand Down
47 changes: 45 additions & 2 deletions brain-bar/Sources/BrainBar/QuickCapturePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ final class QuickCaptureViewModel: ObservableObject {
private var copyResetTask: Task<Void, Never>?
private var feedbackResetTask: Task<Void, Never>?
private let searchDebounceDelay: Duration
private var lastSearchKeystrokeAt: TimeInterval?
private var lastSearchQueryID: String?
var _pendingSearchTask: Task<Void, Never>?
/// Exposed for test awaiting — set when an async store is in flight.
var _pendingStoreTask: Task<Void, Never>?
Expand Down Expand Up @@ -272,6 +274,10 @@ final class QuickCaptureViewModel: ObservableObject {
_pendingSearchTask?.cancel()
return
}
if SearchProfileLogger.isEnabled {
lastSearchKeystrokeAt = SearchProfileLogger.now()
lastSearchQueryID = SearchProfileLogger.newQueryID()
}
scheduleDebouncedSearch()
}

Expand Down Expand Up @@ -372,31 +378,68 @@ final class QuickCaptureViewModel: ObservableObject {

private func scheduleDebouncedSearch() {
let query = inputText
let profileKeystrokeAt = lastSearchKeystrokeAt
let profileQueryID = lastSearchQueryID
_pendingSearchTask?.cancel()
_pendingSearchTask = Task { @MainActor [weak self] in
guard let self else { return }
try? await Task.sleep(for: searchDebounceDelay)
guard !Task.isCancelled, query == inputText else { return }
submitSearch(query: query, cancelPending: false)
let submitStartedAt = SearchProfileLogger.now()
if let profileKeystrokeAt {
SearchProfileLogger.log(
scope: "search.brainbar",
step: "keystroke_submit",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: profileKeystrokeAt)
)
}
submitSearch(
query: query,
cancelPending: false,
profileQueryID: profileQueryID,
profileSubmitStartedAt: submitStartedAt
)
}
}

private func submitSearch(query: String? = nil, cancelPending: Bool = true) {
private func submitSearch(
query: String? = nil,
cancelPending: Bool = true,
profileQueryID: String? = nil,
profileSubmitStartedAt: TimeInterval? = nil
) {
if cancelPending {
_pendingSearchTask?.cancel()
}
let profileQueryID = profileQueryID ?? (SearchProfileLogger.isEnabled ? SearchProfileLogger.newQueryID() : nil)
let submitStartedAt = profileSubmitStartedAt ?? SearchProfileLogger.now()
let query = query ?? inputText
do {
let searchResult = try search(query, 8)
results = searchResult.results.compactMap(QuickCaptureSearchRow.fromSearchResult)
selectedResultIndex = results.isEmpty ? nil : 0
copiedResultID = nil
feedback = .idle
SearchProfileLogger.log(
scope: "search.brainbar",
step: "render_done",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: submitStartedAt),
fields: ["result_count": results.count]
)
} catch {
results = []
selectedResultIndex = nil
copiedResultID = nil
feedback = .error(error.localizedDescription)
SearchProfileLogger.log(
scope: "search.brainbar",
step: "render_done",
queryID: profileQueryID,
durMS: SearchProfileLogger.durationMS(since: submitStartedAt),
fields: ["error": String(describing: error)]
)
}
}

Expand Down
65 changes: 65 additions & 0 deletions brain-bar/Sources/BrainBar/SearchProfileLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

enum SearchProfileLogger {
static var isEnabled: Bool {
ProcessInfo.processInfo.environment["BRAINLAYER_SEARCH_PROFILE"] == "1"
}

static func newQueryID() -> String {
"q-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))"
}

static func now() -> TimeInterval {
ProcessInfo.processInfo.systemUptime
}

static func durationMS(since startedAt: TimeInterval) -> Double {
((now() - startedAt) * 1000).rounded(toPlaces: 3)
}

static func log(
scope: String,
step: String,
queryID: String?,
durMS: Double? = nil,
fields: [String: Any] = [:]
) {
guard isEnabled else { return }

var event: [String: Any] = [
"ts": isoTimestamp(),
"scope": scope,
"step": step
]
if let queryID {
event["query_id"] = queryID
}
if let durMS {
event["dur_ms"] = durMS
}
let reservedKeys: Set<String> = ["ts", "scope", "step", "query_id", "dur_ms"]
for (key, value) in fields where !reservedKeys.contains(key) {
event[key] = value
}

guard JSONSerialization.isValidJSONObject(event),
let data = try? JSONSerialization.data(withJSONObject: event, options: [.sortedKeys]),
let line = String(data: data, encoding: .utf8) else {
return
}
NSLog("%@", line)
}

private static func isoTimestamp() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: Date())
}
}

private extension Double {
func rounded(toPlaces places: Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
65 changes: 65 additions & 0 deletions brain-bar/Sources/BrainBarDaemon/SearchProfileLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

enum SearchProfileLogger {
static var isEnabled: Bool {
ProcessInfo.processInfo.environment["BRAINLAYER_SEARCH_PROFILE"] == "1"
}

static func newQueryID() -> String {
"q-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))"
}

static func now() -> TimeInterval {
ProcessInfo.processInfo.systemUptime
}

static func durationMS(since startedAt: TimeInterval) -> Double {
((now() - startedAt) * 1000).rounded(toPlaces: 3)
}

static func log(
scope: String,
step: String,
queryID: String?,
durMS: Double? = nil,
fields: [String: Any] = [:]
) {
guard isEnabled else { return }

var event: [String: Any] = [
"ts": isoTimestamp(),
"scope": scope,
"step": step
]
if let queryID {
event["query_id"] = queryID
}
if let durMS {
event["dur_ms"] = durMS
}
let reservedKeys: Set<String> = ["ts", "scope", "step", "query_id", "dur_ms"]
for (key, value) in fields where !reservedKeys.contains(key) {
event[key] = value
}

guard JSONSerialization.isValidJSONObject(event),
let data = try? JSONSerialization.data(withJSONObject: event, options: [.sortedKeys]),
let line = String(data: data, encoding: .utf8) else {
return
}
NSLog("%@", line)
}

private static func isoTimestamp() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: Date())
}
}

private extension Double {
func rounded(toPlaces places: Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused daemon profile logger

Low Severity

SearchProfileLogger was added under BrainBarDaemon, but nothing in that target references it; only the BrainBar copy is used for hybrid search instrumentation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8c81107. Configure here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Phase 2.4-G Search Profile Instrumentation Mandate

Status: instrumentation-only PR.

Etan reported search latency around 15 seconds after Phase 2.4-F/PR #320 was expected to make warm hybrid search fast. This PR deliberately does not optimize the search path. It adds opt-in timing logs so the next pass can identify the slow segment from data instead of speculation.

## Enablement

Set `BRAINLAYER_SEARCH_PROFILE=1` to emit verbose timing events. With the flag unset, the instrumentation is silent.

Each event is a single JSON object with:

- `ts`: UTC ISO timestamp
- `scope`: `search.brainbar`, `search.helper`, `search.mcp`, or the supplied search scope
- `step`: measured step name
- `query_id`: best-effort correlation ID
- `dur_ms`: duration for completed timed steps, when applicable
- `error`: exception class for failed timed steps, when applicable

## Instrumented Points

1. BrainBar command bar keystroke debounce to `submitSearch`: `step=keystroke_submit`.
2. BrainBar MCP router dispatch into `handleBrainSearch`: `step=router_dispatch`.
3. BrainBar helper RPC start and completion: `step=helper_rpc_start`, `step=helper_rpc_done`.
4. Python helper/MCP embedding call: `step=embed`.
5. Python hybrid search leg: `step=hybrid_search`.
6. Hybrid internals: `step=binary_knn`, `step=float_rerank`, and `step=fts_merge`.
7. Helper startup warm state: `step=startup_warm_state`, including `warm_called`, `binary_index_available`, and `binary_knn_mmap_size`.
8. BrainBar result rendering completion: `step=render_done`.

## Reading Guidance

Run a real query with the flag enabled and group by `query_id`. Compare `dur_ms` across `embed`, `binary_knn`, `float_rerank`, `fts_merge`, `hybrid_search`, `helper_rpc_done`, and `render_done`. The largest duration is the first candidate for the follow-up performance fix.

Do not infer a performance fix from this PR alone. Use the captured production log lines first.
Loading
Loading