Bloombox/Swift

View on GitHub
Sources/Client/TelemetryClient+Commercial.swift

Summary

Maintainability
D
1 day
Test Coverage
/**
* Copyright 2019, Momentum Ideas, Co. All rights reserved.
* Source and object computer code contained herein is the private intellectual
* property of Momentum Ideas Co., a Delaware Corporation. Use of this
* code in source form requires permission in writing before use or the
* assembly, distribution, or publishing of derivative works, for commercial
* purposes or any other purpose, from a duly authorized officer of Momentum
* Ideas Co.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import Foundation


/// Enumerates errors that may be thrown during operations related to commercial events.
public enum CommercialEventError: Error {
  /// An unspecified internal error occurred.
  case internalError

  /// The specified section did not match the specified product type.
  case sectionMismatch
}


/// Extends the base `TelemetryClient` with so-called *Commercial Telemetry* features. Commercial
/// events are specialized telemetry payloads which present an interface that models a standard
/// *marketing funnel*, with stages like *Impression*, *View*, and *Action*. Using these payloads for
/// for corresponding event types, rather than *Generic Telemetry*, enables rich analysis features and
/// funnel-style reporting.
///
/// ### Event Context
/// Commercial telemetry events still carry standard event telemetry, along with some extra support for
/// commercially-relevant properties. These include, where applicable:
/// - *Active user*: The active user ID can be assigned in commercial event context.
/// - *Active order*: The active order ID can be assigned in commercial event context, enabling correlation
///   between events that relate to the same order.
///
/// ### Explicit UUIDs
/// Explicit event UUIDs allow the telemetry data originator to assign unique IDs to events before they are ingested.
/// This feature enables de-duplication of events server-side, making event submission an idempotent activity. If IDs
/// are not assigned by the client, the server assigns IDs during event ingest.
///
/// ### Commercial Events
/// Commercial telemetry methods are broken up into three categories:
/// - *Section-based*: Section-level objects include *View* and *Action*.  *View* events refer to product master
///   listings, and usually accompany product impressions. Section-level *Action* events might include sorting,
///   filtering, or searching a master listing of products.
/// - *Product-based*: Product-level objects include *Impression*, *View* and *Action*. *Impression* events for
///   products specify a product that was shown as an option to a consumer, amongst other products, usually as
///   part of a master listing of products. Product *Action* events refer to occurrences like:
///   - `CART`: Adding a product to a bag or cart
///   - `ZOOM`: Zooming in on a product's photo
///   - `SHARE`: Sharing a product socially
///   - `REPORTS`: Examining detailed reports for a product
/// - *Order-based*: Order-level events are *Actions* only, and include occurrences such as:
///   - `ADD_TO_CART`: Order event corresponding to adding an item to a cart
///   - `REMOVE_FROM_CART`: Removing an item from an order's cart
///   - `RESTORE_CART`: Restoring some previous cart
///   - `ORDER_SUBMIT`: Submission of an order to a retailer
///   - `ORDER_STATUS`: Check order status from an app, kiosk, or site
///   - `ORDER_CANCEL`: Order was cancelled by the user for some reason
///
/// ### API Reference
/// Methods provided include:
///
/// #### Section Events
/// - `sectionImpression`: Record an impression for a product section.
/// - `sectionView`: Record a product master listing view event.
/// - `sectionAction`: Record an action taken on a section master listing.
///
/// #### Product Events
/// - `productImpression`: Record an impression for an individual product.
/// - `productView`: Record a view event for an individual product.
/// - `productAction`: Record an action taken on an individual product record.
///
/// #### Order Events
/// - `orderAction`: Record an action taken by a user on an order.
extension TelemetryClient {
  fileprivate func resolveCommercialContext(type: CommercialEvent,
                                            section: MenuSection? = nil,
                                            product: ProductKey? = nil,
                                            activeUser: UserKey? = nil,
                                            activeOrder: OrderID? = nil,
                                            partner: PartnerCode? = nil,
                                            location: LocationCode? = nil,
                                            deviceName: DeviceCode? = nil,
                                            apiKey: APIKey? = nil,
                                            context: EventContext? = nil) throws -> AnalyticsContext {
    let (partnerCode, locationCode, deviceCode, _) = try self.resolveContext(
      partner, location, deviceName, apiKey)

    // merge/resolve event context
    var merged: AnalyticsContext
    if let c = context {
      var exported = c.export()
      let globalContext = settings.export()
      let serialized = try globalContext.serializedData()
      try exported.merge(serializedData: serialized)
      merged = exported
    } else {
      merged = settings.export()
    }
    merged.collection = EventCollection.commercial(type).export()
    if let user = activeUser {
      merged.userKey = user
    }
    if let order = activeOrder {
      merged.scope.order = order
    }
    if let s = section?.name {
      if let p = product, let ps = p.type.section?.name {
        if ps != s {
          throw CommercialEventError.sectionMismatch
        }
        merged.scope.commercial = "section/\(ps)/product/\(p.id)"
      } else {
        merged.scope.commercial = "section/\(s)"
      }
    } else if let p = product, let ps = p.type.section?.name {
      merged.scope.commercial = "section/\(ps)/product/\(p.id)"
    }
    merged.scope.partner = "partner/\(partnerCode)/location/\(locationCode)/device/\(deviceCode)"
    return merged
  }

  // MARK: - Section Events

  /// Submit an *Impression* event for a menu section, describing an occurrence where a menu
  /// section was presented to a user as an option for navigation. The user does not have to
  /// perform any affirmative action to trigger this event.
  ///
  /// - Parameter section: Section that was shown to the user.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter activeUser: Active user at the time the section was shown.
  /// - Parameter activeOrder: Active order at the time the section was shown.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func sectionImpression(section: MenuSection,
                                uuid: UUID? = nil,
                                activeUser: UserKey? = nil,
                                activeOrder: OrderID? = nil,
                                partner: PartnerCode? = nil,
                                location: LocationCode? = nil,
                                deviceName: DeviceCode? = nil,
                                apiKey: APIKey? = nil,
                                context: EventContext? = nil,
                                callback: GenericEventCallback? = nil) throws -> TelemetryImpressionCall {
    let eventContext = try self.resolveCommercialContext(
      type: .impression(.section),
      section: section,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.impression(CommercialTelemetryEvent.Impression.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.section = SectionImpression.with { builder in
        builder.spec = SectionSpec.with { builder in
          builder.section = section
        }
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  /// Submit a *View* event for a menu section, describing an occurrence where a user chose
  /// a given section for navigation after being presented with a set of menu sections. This event
  /// involves affirmative user action - i.e. navigating to a given menu section. It should not be
  /// sent for sections shown by default.
  ///
  /// - Parameter section: Section that was navigated to by the user.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter activeUser: Active user at the time the section was chosen.
  /// - Parameter activeOrder: Active order at the time the section was chosen.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func sectionView(section: MenuSection,
                          uuid: UUID? = nil,
                          activeUser: UserKey? = nil,
                          activeOrder: OrderID? = nil,
                          partner: PartnerCode? = nil,
                          location: LocationCode? = nil,
                          deviceName: DeviceCode? = nil,
                          apiKey: APIKey? = nil,
                          context: EventContext? = nil,
                          callback: GenericEventCallback? = nil) throws -> TelemetryViewCall {
    let eventContext = try self.resolveCommercialContext(
      type: .view(.section),
      section: section,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.view(CommercialTelemetryEvent.View.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.section = SectionView.with { builder in
        builder.spec = SectionSpec.with { builder in
          builder.section = section
        }
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  /// Submit an *Action* event for a menu section, describing an occurrence where a user
  /// performed some action within a given master-listing, bound to a menu section. This event
  /// is necessarily after a section *View*, and always involves affirmative user action.
  ///
  /// - Parameter section: Section that was acted upon to by the user.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter action: Action that was taken on the section by the user.
  /// - Parameter activeUser: Active user at the time the section was acted upon.
  /// - Parameter activeOrder: Active order at the time the section was acted upon.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func sectionAction(section: MenuSection,
                            action: SectionActionType,
                            uuid: UUID? = nil,
                            activeUser: UserKey? = nil,
                            activeOrder: OrderID? = nil,
                            partner: PartnerCode? = nil,
                            location: LocationCode? = nil,
                            deviceName: DeviceCode? = nil,
                            apiKey: APIKey? = nil,
                            context: EventContext? = nil,
                            callback: GenericEventCallback? = nil) throws -> TelemetryActionCall {
    let eventContext = try self.resolveCommercialContext(
      type: .action(.section),
      section: section,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.action(CommercialTelemetryEvent.Action.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.section = SectionAction.with { builder in
        builder.verb = action
        builder.spec = SectionSpec.with { builder in
          builder.section = section
        }
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  // MARK: - Product Events

  /// Submit an *Impression* event for an individual product, describing an occurrence where the
  /// referenced product was presented to a user as an option for navigation, amongst a set of
  /// multiple options. The user does not have to perform any affirmative action to trigger this event.
  ///
  /// - Parameter product: Product that was shown to the user.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter activeUser: Active user at the time the product was shown.
  /// - Parameter activeOrder: Active order at the time the product was shown.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func productImpression(product: ProductKey,
                                uuid: UUID? = nil,
                                activeUser: UserKey? = nil,
                                activeOrder: OrderID? = nil,
                                partner: PartnerCode? = nil,
                                location: LocationCode? = nil,
                                deviceName: DeviceCode? = nil,
                                apiKey: APIKey? = nil,
                                context: EventContext? = nil,
                                callback: GenericEventCallback? = nil) throws -> TelemetryImpressionCall {
    let eventContext = try self.resolveCommercialContext(
      type: .impression(.product),
      product: product,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.impression(CommercialTelemetryEvent.Impression.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.product = ProductImpression.with { builder in
        builder.key = product
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  /// Submit a *View* event for an individual product, describing an occurrence where a user chose
  /// a given product for navigation after being presented with a set of choices, usually within the
  /// context of a master listing of products. This event involves affirmative user action - i.e.
  /// navigating to a given menu section. It should not be sent for products shown by default.
  ///
  /// - Parameter product: Product that the user chose to navigate to.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter activeUser: Active user at the time the product was chosen.
  /// - Parameter activeOrder: Active order at the time the product was chosen.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func productView(product: ProductKey,
                          uuid: UUID? = nil,
                          activeUser: UserKey? = nil,
                          activeOrder: OrderID? = nil,
                          partner: PartnerCode? = nil,
                          location: LocationCode? = nil,
                          deviceName: DeviceCode? = nil,
                          apiKey: APIKey? = nil,
                          context: EventContext? = nil,
                          callback: GenericEventCallback? = nil) throws -> TelemetryViewCall {
    let eventContext = try self.resolveCommercialContext(
      type: .view(.product),
      product: product,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.view(CommercialTelemetryEvent.View.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.product = ProductView.with { builder in
        builder.key = product
        builder.interactive = true
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  /// Submit an *Action* event for an individual product, describing an occurrence where a user
  /// performed some action within a given detail-listing, bound to a known catalog product. This
  /// event is necessarily after a product *View*, and always involves affirmative user action.
  ///
  /// Some product actions, such as `CART` and `PURCHASE`, are considered *conversion*
  /// events, which, for the purposes of analytics and metering calculations, count as events that
  /// drive revenue.
  ///
  /// - Parameter section: Product that the user took action on.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter action: Action that was taken on the product by the user.
  /// - Parameter activeUser: Active user at the time the action was taken.
  /// - Parameter activeOrder: Active order at the time the product was acted upon.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func productAction(product: ProductKey,
                            action: ProductActionType,
                            uuid: UUID? = nil,
                            activeUser: UserKey? = nil,
                            activeOrder: OrderID? = nil,
                            partner: PartnerCode? = nil,
                            location: LocationCode? = nil,
                            deviceName: DeviceCode? = nil,
                            apiKey: APIKey? = nil,
                            context: EventContext? = nil,
                            callback: GenericEventCallback? = nil) throws -> TelemetryActionCall {
    let eventContext = try self.resolveCommercialContext(
      type: .action(.product),
      product: product,
      activeUser: activeUser,
      activeOrder: activeOrder,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.action(CommercialTelemetryEvent.Action.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.product = ProductAction.with { builder in
        builder.key = product
        builder.verb = action
        builder.occurred = TemporalInstant.now()
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

  // MARK: - Order Events

  /// Submit an *Action* event for a commercial order, describing an occurrence where a user
  /// performed some action related to their cart, or an eligible order, or an in-flight order. This
  /// event type necessarily involves affirmative user action.
  ///
  /// Some product actions, such as `ORDER_ACCEPTED` and `ADD_TO_CART`, are considered
  /// *conversion* events, which, for the purposes of analytics and metering calculations, count as
  /// events that drive revenue.
  ///
  /// - Parameter order: Order object upon which action was taken.
  /// - Parameter uuid: Explicit event UUID to affix before transmission.
  /// - Parameter action: Action that was taken on the order by the user.
  /// - Parameter activeUser: Active user at the time the action was taken.
  /// - Parameter partner: Code for the partner under which this event occurred.
  /// - Parameter location: Code for the location at, or under, which this event occurred.
  /// - Parameter deviceName: Name or serial number of the reporting device.
  /// - Parameter apiKey: API key to use for this event submission operation.
  /// - Parameter context: Explicit context to merge and apply to the event.
  /// - Parameter callback: Callback to dispatch after we're done, or encounter an error.
  /// - Throws: Client-side errors for missing data (see: `CommercialEventError`).
  /// - Returns: gRPC call, which can be used to observe or cancel the in-flight operation.
  @discardableResult
  public func orderAction(order: Order,
                          action: OrderActionType,
                          uuid: UUID? = nil,
                          activeUser: UserKey? = nil,
                          partner: PartnerCode? = nil,
                          location: LocationCode? = nil,
                          deviceName: DeviceCode? = nil,
                          apiKey: APIKey? = nil,
                          context: EventContext? = nil,
                          callback: GenericEventCallback? = nil) throws -> TelemetryActionCall {
    let eventContext = try self.resolveCommercialContext(
      type: .action(.product),
      activeUser: activeUser,
      activeOrder: order.id,
      partner: partner,
      location: location,
      deviceName: deviceName,
      apiKey: apiKey,
      context: context)

    return try self.commercial.action(CommercialTelemetryEvent.Action.with { builder in
      if let id = uuid {
        builder.uuid = id.uuidString.uppercased()
      }
      builder.context = eventContext
      builder.order = OrderAction.with { builder in
        builder.verb = action
        builder.occurred = TemporalInstant.now()
        builder.orderKey = OrderKey.with { builder in
          builder.id = order.id
        }
        builder.customer = order.customer
      }
    }) { (response, callResult) in
      callback?(callResult)
    }
  }

}