Enable on-site search signals in an iOS app

The on-site search signal captures the queries users enter in your app's search screen and the number of results returned. It helps identify popular search terms, discover content gaps and segment users by the terms they searched for - for example, a search for "mortgage" or "business insurance" can trigger a targeted email or push notification with an offer.

Availability: Premium and Ultimate

Languages: Swift and Objective-C


Implementation considerations

When to fire the signal

In iOS apps, UISearchResultsUpdating calls updateSearchResults(for:) on every keystroke. Choose a firing strategy that captures a deliberate search rather than every character:

  • Debounced text change - fire after the user stops typing for a set interval (for example, 500 ms). Recommended when results update as the user types and there is no explicit submit step. Use DispatchWorkItem with DispatchQueue.main.asyncAfter to cancel and reschedule on each keystroke.
  • Search bar submit - fire when the user taps the Search key on the keyboard via UISearchBarDelegate.searchBarSearchButtonClicked(_:). Best for search screens with an explicit submit step.
  • On view disappearance - fire in viewWillDisappear(_:) to capture the final query state when the user navigates away. Useful when neither of the above is practical.

Effect values

Use the effect field to indicate whether the search returned results:

  • "positive" - the search returned one or more results
  • "negative" - the search returned zero results

Contact mapping

Include the audience field to attribute this signal to a contact in Connect. See How behavior signals update Connect data for identifier formats and contact resolution details.


Configuration

Before adding behavior signals, integrate the Connect SDK into your app. See the guide for your development language: Swift or Objective-C.

Method

func logSignal(_ data: [String: Any]?) -> Bool

Sends the signal to the Acoustic Connect endpoint. Initialize the Connect SDK before calling this method using ConnectSDK.shared.enable(with:).

Pass all fields as a flat [String: Any] dictionary. The SDK handles the signal structure automatically.

Signal fields

Required

FieldTypeDescription
numberOfResultsNumberNumber of results returned for the search term
searchTermStringThe word or phrase the user searched for
signalTypeStringSignal type. Value: "onSiteSearch".

Optional

FieldTypeDescription
audience[String: Any]Key-value pairs for contact mapping. Keys must match contact attribute names exactly as they appear in Connect, including capitalization and spacing.
categoryStringSignal category. Value: "Behavior".
descriptionStringA description of the signal
effectStringDescribes the effect of the signal on engagement. Use "positive" when results are found, "negative" when the search returns zero results.
nameStringA label to differentiate this signal from others (for example, "product search"). Max 256 characters

Things to know

  • numberOfResults accepts a native Swift Int.

  • If a required field is missing or invalid, the entire signal is discarded.

Basic example

import Connect

ConnectCustomEvent.sharedInstance().logSignal([
    "signalType": "onSiteSearch",
    "searchTerm": "wireless headphones",
    "numberOfResults": 24,
    "effect": "positive"
])
#import <Connect/Connect.h>

[[ConnectCustomEvent sharedInstance] logSignal:@{
    @"signalType": @"onSiteSearch",
    @"searchTerm": @"wireless headphones",
    @"numberOfResults": @24,
    @"effect": @"positive"
}];

Complete example

This example fires an on-site search signal in a live-search implementation. A 500 ms debounce ensures the signal fires after the user pauses, not on every keystroke. Cancel the debounce on dismiss to avoid firing a signal after the screen is dismissed.

import Connect

// MARK: - Debounce state

private var searchDebounce: DispatchWorkItem?

// MARK: - Signal helper

private func sendOnSiteSearchSignal(query: String, resultCount: Int) {
    var signal: [String: Any] = [
        "signalType": "onSiteSearch",
        "searchTerm": query,
        "numberOfResults": resultCount,
        "effect": resultCount > 0 ? "positive" : "negative"
    ]

    if UserSession.shared.isAuthenticated, let email = UserSession.shared.email {
        signal["audience"] = ["Email Address": email]
    }

    let accepted = ConnectCustomEvent.sharedInstance().logSignal(signal)
    NSLog("[Connect] onSiteSearch signal accepted: \(accepted)")
}

// MARK: - UISearchResultsUpdating

extension SearchViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        refreshResults()  // update the table immediately

        let query = searchController.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
        guard !query.isEmpty else { return }

        searchDebounce?.cancel()
        let work = DispatchWorkItem { [weak self] in
            guard let self else { return }
            self.sendOnSiteSearchSignal(query: query, resultCount: self.results.count)
        }
        searchDebounce = work
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
    }
}

// MARK: - Cancel debounce on dismiss

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    searchDebounce?.cancel()
}
import Connect

struct SearchScreen: View {
    @State private var searchText = ""
    @State private var results: [Product] = Catalog.allProducts
    @State private var searchTask: Task<Void, Never>?

    var body: some View {
        List(results) { product in
            // ...
        }
        .searchable(text: $searchText, prompt: "Search the shop")
        .onChange(of: searchText) { query in
            let trimmed = query.trimmingCharacters(in: .whitespaces)
            refreshResults(query: trimmed)

            guard !trimmed.isEmpty else { return }
            searchTask?.cancel()
            searchTask = Task {
                try? await Task.sleep(nanoseconds: 500_000_000)
                guard !Task.isCancelled else { return }
                sendOnSiteSearchSignal(query: trimmed, resultCount: results.count)
            }
        }
        .onDisappear {
            searchTask?.cancel()
        }
    }

    private func sendOnSiteSearchSignal(query: String, resultCount: Int) {
        var signal: [String: Any] = [
            "signalType": "onSiteSearch",
            "searchTerm": query,
            "numberOfResults": resultCount,
            "effect": resultCount > 0 ? "positive" : "negative"
        ]

        if UserSession.shared.isAuthenticated, let email = UserSession.shared.email {
            signal["audience"] = ["Email Address": email]
        }

        let accepted = ConnectCustomEvent.sharedInstance().logSignal(signal)
        NSLog("[Connect] onSiteSearch signal accepted: \(accepted)")
    }
}
#import <Connect/Connect.h>

// Signal helper
- (void)sendOnSiteSearchSignalWithQuery:(NSString *)query resultCount:(NSInteger)resultCount {
    NSMutableDictionary *signal = [@{
        @"signalType": @"onSiteSearch",
        @"searchTerm": query,
        @"numberOfResults": @(resultCount),
        @"effect": resultCount > 0 ? @"positive" : @"negative"
    } mutableCopy];

    if ([[UserSession shared] isAuthenticated] && [[UserSession shared] email]) {
        signal[@"audience"] = @{ @"Email Address": [[UserSession shared] email] };
    }

    BOOL accepted = [[ConnectCustomEvent sharedInstance] logSignal:signal];
    NSLog(@"[Connect] onSiteSearch signal accepted: %@", accepted ? @"true" : @"false");
}

// UISearchResultsUpdating
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
    [self refreshResults];  // update the table immediately

    NSString *query = [searchController.searchBar.text
        stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet] ?: @"";
    if (query.length == 0) return;

    [NSObject cancelPreviousPerformRequestsWithTarget:self
              selector:@selector(fireSearchSignal:)
              object:nil];
    [self performSelector:@selector(fireSearchSignal:)
               withObject:query
               afterDelay:0.5];
}

- (void)fireSearchSignal:(NSString *)query {
    [self sendOnSiteSearchSignalWithQuery:query resultCount:self.results.count];
}

// Cancel debounce on dismiss
- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [NSObject cancelPreviousPerformRequestsWithTarget:self];
}

Verification

After typing a query in the search bar and pausing for 500 ms, check the Xcode console for:

[Connect] onSiteSearch signal accepted: true

The false value means the SDK rejected the call - see Troubleshooting below. The signal does not fire on every keystroke - only after the debounce interval elapses. Once the console confirms acceptance, verify the signal is available in Connect.


Troubleshooting

Signal not appearing in Connect?

  • Confirm the Connect SDK is initialized before any signal calls. See Method.
  • Capture the return value and check that it is true: let accepted = ConnectCustomEvent.sharedInstance().logSignal(signal). A false return indicates the SDK rejected the call.
  • Verify the appKey and postURL match the Connect org you're checking.

Signal marked as invalid in Connect?

  • Confirm all required fields are present: signalType, searchTerm, numberOfResults.

Signal fires on every keystroke?

  • Add debouncing as shown in the complete example. A DispatchWorkItem cancelled and rescheduled on each updateSearchResults call is the standard iOS pattern.
  • Alternatively, implement UISearchBarDelegate.searchBarSearchButtonClicked(_:) and fire only on explicit submit.

Contact not created or updated?

  • Verify attribute key names in audience match exactly how they appear in Connect - including capitalization and spacing.

Related pages


Did this page help you?