Retroacciones de BigQuery con acciones de Looker en funciones de Cloud Run

Muchos clientes de Looker desean permitir que sus usuarios vayan más allá de generar informes sobre los datos en su almacén de datos y, en cambio, escriban en ese almacén de datos y lo actualicen.

A través de su API de Action, Looker admite este caso de uso para cualquier almacén de datos o destino. En esta página de documentación, se guía a los clientes que usan la infraestructura de Google Cloud para implementar una solución en funciones de Cloud Run y escribir en BigQuery. En esta página, se abordan los siguientes temas:

Consideraciones sobre la solución

Usa esta lista de consideraciones para validar que esta solución se alinea con tus necesidades.

  • Cloud Run Functions
    • ¿Por qué usar Cloud Run Functions? Como oferta "sin servidores" de Google, Cloud Run Functions es una excelente opción por su facilidad de operación y mantenimiento. Una consideración que debes tener en cuenta es que la latencia, en especial para las invocaciones en frío, puede ser más larga que con una solución que se basa en un servidor dedicado.
    • Lenguaje y entorno de ejecución: Cloud Run Functions admite varios lenguajes y entornos de ejecución. En esta página de documentación, nos centraremos en un ejemplo en JavaScript y Node.js. Sin embargo, los conceptos se pueden traducir directamente a los otros idiomas y tiempos de ejecución admitidos.
  • BigQuery
    • ¿Por qué BigQuery? Si bien esta página de documentación supone que ya usas BigQuery, este es una excelente opción para un almacén de datos en general. Ten en cuenta las siguientes consideraciones:
      • API de BigQuery Storage Write: BigQuery ofrece varias interfaces para actualizar datos en tu almacén de datos, incluidas, por ejemplo, las instrucciones del lenguaje de manipulación de datos (DML) en trabajos basados en SQL. Sin embargo, la mejor opción para las escrituras de gran volumen es la API de BigQuery Storage Write.
      • Agregar en lugar de actualizar: Si bien esta solución solo agregará filas, no las actualizará, siempre puedes derivar tablas de "estado actual" en el momento de la consulta a partir de un registro de solo anexión, lo que simula las actualizaciones.
  • Servicios compatibles
    • Secret Manager: Secret Manager contiene valores secretos para garantizar que no se almacenen en lugares demasiado accesibles, como directamente en la configuración de la función.
    • Identity and Access Management (IAM): IAM autoriza a la función a acceder al secreto necesario en el tiempo de ejecución y a escribir en la tabla de BigQuery deseada.
    • Cloud Build: Si bien Cloud Build no se analizará en profundidad en esta página, Cloud Run Functions lo usa en segundo plano, y puedes usar Cloud Build para automatizar las actualizaciones implementadas de forma continua en tus funciones a partir de los cambios en tu código fuente en un repositorio de Git.
  • Autenticación de acciones y usuarios
    • Cuenta de servicio de Cloud Run: La forma principal y más sencilla de usar las acciones de Looker para la integración con los recursos y activos de origen propios de tu organización es autenticar las solicitudes como provenientes de tu instancia de Looker con el mecanismo de autenticación basado en tokens de la API de Looker Action y, luego, autorizar la función para actualizar los datos en BigQuery con una cuenta de servicio.
    • OAuth: Otra opción, que no se aborda en esta página, sería usar la función de OAuth de la API de Looker Action. Este enfoque es más complejo y, por lo general, no es necesario, pero se puede usar si necesitas definir el acceso de los usuarios finales para escribir en la tabla con IAM, en lugar de usar su acceso en Looker o la lógica ad hoc dentro del código de tu función.

Explicación del código de demostración

Tenemos un solo archivo que contiene toda la lógica de la acción de demostración disponible en GitHub. En esta sección, analizaremos los elementos clave del código.

Código de configuración

La primera sección tiene algunas constantes de demostración que identifican la tabla en la que se escribirá la acción. En la sección Guía de implementación que se encuentra más adelante en esta página, se te indicará que reemplaces el ID del proyecto por el tuyo, que será la única modificación necesaria en el código.

/*** Demo constants */
const projectId = "your-project-id"
const datasetId = "demo_dataset"
const tableId = "demo_table"

En la siguiente sección, se declaran e inicializan algunas dependencias de código que usará tu acción. Proporcionamos un ejemplo que accede a Secret Manager "en el código" con el módulo de Secret Manager para Node.js. Sin embargo, también puedes eliminar esta dependencia de código con la función integrada de Cloud Run para recuperar un secreto durante su inicialización.

/*** Code Dependencies ***/
const crypto = require("crypto")
const {SecretManagerServiceClient} = require('@google-cloud/secret-manager')
const secrets = new SecretManagerServiceClient()
const BigqueryStorage = require('@google-cloud/bigquery-storage')
const BQSManagedWriter = BigqueryStorage.managedwriter

Ten en cuenta que las dependencias de @google-cloud a las que se hace referencia también se declaran en nuestro archivo package.json para permitir que las dependencias se carguen previamente y estén disponibles para nuestro entorno de ejecución de Node.js. crypto es un módulo integrado de Node.js y no se declara en package.json.

Control y enrutamiento de solicitudes HTTP

La interfaz principal que tu código expone al entorno de ejecución de Cloud Run Functions es una función de JavaScript exportada que sigue las convenciones del servidor web de Node.js Express. En particular, tu función recibe dos argumentos: el primero representa la solicitud HTTP, desde la cual puedes leer varios parámetros y valores de la solicitud; y el segundo representa un objeto de respuesta, al que emites tus datos de respuesta. Si bien el nombre de la función puede ser el que desees, deberás proporcionárselo a Cloud Run Functions más adelante, como se detalla en la sección de la guía de implementación.

/*** Entry-point for requests ***/
exports.httpHandler = async function httpHandler(req,res) {

La primera sección de la función httpHandler declara las distintas rutas que reconocerá nuestra acción, lo que refleja de cerca los extremos requeridos de la API de Action para una sola acción y las funciones que controlarán cada ruta, que se definen más adelante en el archivo.

Si bien algunos ejemplos de acciones y Cloud Run Functions implementan una función separada para cada ruta de acceso de este tipo para alinearse de forma individual con el enrutamiento predeterminado de Cloud Run Functions, las funciones son capaces de aplicar un "subenrutamiento" adicional dentro de su código, como se demuestra aquí. En última instancia, esto es una cuestión de preferencia, pero realizar este enrutamiento adicional en el código minimiza la cantidad de funciones que tenemos que implementar y nos ayuda a mantener un solo estado de código coherente en todos los extremos de las acciones.

    const routes = {
        "/": [hubListing],
        "/status": [hubStatus], // Debugging endpoint. Not required.
        "/action-0/form": [
            requireInstanceAuth,
            action0Form
            ], 
        "/action-0/execute": [
            requireInstanceAuth,
            processRequestBody,
            action0Execute
            ]
        }

El resto de la función de controlador HTTP implementa el control de la solicitud HTTP en relación con las declaraciones de ruta anteriores y conecta los valores de devolución de esos controladores al objeto de respuesta.

    try {
        const routeHandlerSequence = routes[req.path] || [routeNotFound]
        for(let handler of routeHandlerSequence) {
            let handlerResponse = await handler(req)
            if (!handlerResponse) continue 
            return res
                .status(handlerResponse.status || 200)
                .json(handlerResponse.body || handlerResponse)
            }
        }
    catch(err) {
        console.error(err)
        res.status(500).json("Unhandled error. See logs for details.")
        }
    }

Con el controlador HTTP y las declaraciones de rutas fuera del camino, nos sumergiremos en los tres principales extremos de acción que tenemos que implementar:

Extremo de la lista de acciones

Cuando un administrador de Looker conecta por primera vez una instancia de Looker a un servidor de acciones, Looker llamará a la URL proporcionada, denominada "endpoint de la lista de acciones", para obtener información sobre las acciones que están disponibles a través del servidor.

En las declaraciones de rutas que mostramos anteriormente, hicimos que este extremo estuviera disponible en la ruta raíz (/) en la URL de nuestra función y, además, indicamos que la función hubListing se encargaría de controlarlo.

Como puedes ver en la siguiente definición de función, no hay mucho "código" en absoluto: solo devuelve los mismos datos JSON cada vez. Un aspecto que se debe tener en cuenta es que incluye de forma dinámica su "propia" URL en algunos de los campos, lo que permite que la instancia de Looker envíe solicitudes posteriores a la misma función.

async function hubListing(req){
    return {
        integrations: [
            {
                name: "demo-bq-insert",
                label: "Demo BigQuery Insert",
                supported_action_types: ["cell", "query", "dashboard"],
                form_url:`${process.env.CALLBACK_URL_PREFIX}/action-0/form`,
                url: `${process.env.CALLBACK_URL_PREFIX}/action-0/execute`,
                icon_data_uri: "data:image/png;base64,...",
                supported_formats:["inline_json"],
                supported_formattings:["unformatted"],
                required_fields:[
                    // You can use this to make your action available
                    // for specific queries/fields
                    // {tag:"user_id"}
                    ],
                params: [
                    // You can use this to require parameters, either
                    // from the Action's administrative configuration,
                    // or from the invoking user's user attributes. 
                    // A common use case might be to have the Looker
                    // instance pass along the user's identification to
                    // allow you to conditionally authorize the action:
                    {name: "email", label: "Email", user_attribute_name: "email", required: true}
                    ]
                }
            ]
        }
    }

Para fines de demostración, nuestro código no requirió autenticación para recuperar esta ficha. Sin embargo, si consideras que los metadatos de tu acción son sensibles, también puedes requerir autenticación para esta ruta, como se muestra en la siguiente sección.

También ten en cuenta que nuestra función de Cloud Run podría exponer y controlar varias acciones, lo que explica nuestra convención de rutas de /action-X/.... Sin embargo, nuestra Cloud Run Function de demostración implementará solo una acción.

Extremo del formulario de acción

Si bien no todos los casos de uso requerirán un formulario, tener uno se adapta bien al caso de uso de la escritura diferida en la base de datos, ya que los usuarios pueden inspeccionar los datos en Looker y, luego, proporcionar valores para insertarlos en la base de datos. Dado que nuestra lista de acciones proporcionó un parámetro form_url, Looker invocará este endpoint de formulario de acción cuando un usuario comience a interactuar con tu acción para determinar qué datos adicionales se deben capturar del usuario.

En nuestras declaraciones de rutas, hicimos que este extremo esté disponible en la ruta de acceso /action-0/form y asociamos dos controladores con él: requireInstanceAuth y action0Form.

Configuramos nuestras declaraciones de rutas para permitir varios controladores como este porque parte de la lógica se puede reutilizar para varios extremos.

Por ejemplo, podemos ver que requireInstanceAuth se usa para varias rutas. Usamos este controlador siempre que queremos exigir que una solicitud provenga de nuestra instancia de Looker. El controlador recupera el valor del token secreto esperado de Secret Manager y rechaza las solicitudes que no tienen ese valor del token esperado.

async function requireInstanceAuth(req) {
    const lookerSecret = await getLookerSecret()
    if(!lookerSecret){return}
    const expectedAuthHeader = `Token token="${lookerSecret}"`
    if(!timingSafeEqual(req.headers.authorization,expectedAuthHeader)){
        return {
            status:401,
            body: {error: "Looker instance authentication is required"}
            }
        }
    return

    function timingSafeEqual(a, b) {
        if(typeof a !== "string"){return}
        if(typeof b !== "string"){return}
        var aLen = Buffer.byteLength(a)
        var bLen = Buffer.byteLength(b)
        const bufA = Buffer.allocUnsafe(aLen)
        bufA.write(a)
        const bufB = Buffer.allocUnsafe(aLen) //Yes, aLen
        bufB.write(b)

        return crypto.timingSafeEqual(bufA, bufB) && aLen === bLen;
        }
    }

Ten en cuenta que usamos una implementación de timingSafeEqual, en lugar de la verificación de igualdad estándar (==), para evitar filtrar información de tiempo de canal secundario que permitiría a un atacante descubrir rápidamente el valor de nuestro secreto.

Suponiendo que una solicitud pasa la verificación de autenticación de la instancia, el controlador action0Form la procesa.

async function action0Form(req){
    return [
        {name: "choice",  label: "Choose", type:"select", options:[
            {name:"Yes", label:"Yes"},
            {name:"No", label:"No"},
            {name:"Maybe", label:"Maybe"}
            ]},
        {name: "note", label: "Note", type: "textarea"}
        ]
    }

Si bien nuestro ejemplo de demostración es muy estático, el código del formulario puede ser más interactivo para ciertos casos de uso. Por ejemplo, según la selección de un usuario en un menú desplegable inicial, se pueden mostrar diferentes campos.

Extremo de ejecución de la acción

El extremo de Action Execute es donde reside la mayor parte de la lógica de cualquier acción y donde analizaremos la lógica específica para el caso de uso de inserción de BigQuery.

En nuestras declaraciones de rutas, hicimos que este extremo esté disponible en la ruta /action-0/execute y asociamos tres controladores con él: requireInstanceAuth, processRequestBody y action0Execute.

Ya analizamos requireInstanceAuth, y el controlador processRequestBody proporciona un procesamiento previo en su mayoría poco interesante para convertir ciertos campos inconvenientes en el cuerpo de la solicitud de Looker en un formato más conveniente, pero puedes consultarlo en el archivo de código completo.

La función action0Execute comienza mostrando ejemplos de cómo extraer información de varias partes de la solicitud de acción que podrían ser útiles. En la práctica, ten en cuenta que los elementos de la solicitud a los que nuestro código hace referencia como formParams y actionParams pueden contener diferentes campos, según lo que declares en tus extremos de Listing y Form.

async function action0Execute (req){
    try{
        // Prepare some data that we will insert
        const scheduledPlanId = req.body.scheduled_plan && req.body.scheduled_plan.scheduled_plan_id
        const formParams = req.body.form_params || {}
        const actionParams = req.body.data || {}
        const queryData = req.body.attachment.data //If using a standard "push" action

        /*In case any fields require datatype-specific preparation, check this example:
        https://github.com/googleapis/nodejs-bigquery-storage/blob/main/samples/append_rows_proto2.js
        */

        const newRow = {
            invoked_at: new Date(),
            invoked_by: actionParams.email,
            scheduled_plan_id: scheduledPlanId || null,
            query_result_size: queryData.length,
            choice: formParams.choice,
            note: formParams.note,
            }

Luego, el código pasa a un código estándar de BigQuery para insertar los datos. Ten en cuenta que las APIs de BigQuery Storage Write ofrecen otras variaciones más complejas que son más adecuadas para una conexión de transmisión persistente o inserciones masivas de muchos registros, pero, para responder a las interacciones individuales del usuario en el contexto de una Cloud Run Function, esta es la variación más directa.

await bigqueryConnectAndAppend(newRow)

...

async function bigqueryConnectAndAppend(row){   
    let writerClient
    try{
        const destinationTablePath = `projects/${projectId}/datasets/${datasetId}/tables/${tableId}`
        const streamId = `${destinationTablePath}/streams/_default`
        writerClient = new BQSManagedWriter.WriterClient({projectId})
        const writeMetadata = await writerClient.getWriteStream({
            streamId,
            view: 'FULL',
            })
        const protoDescriptor = BigqueryStorage.adapt.convertStorageSchemaToProto2Descriptor(
            writeMetadata.tableSchema,
            'root'
            )
        const connection = await writerClient.createStreamConnection({
            streamId,
            destinationTablePath,
            })
        const writer = new BQSManagedWriter.JSONWriter({
            streamId,
            connection,
            protoDescriptor,
            })

        let result
        if(row){
            // The API expects an array of rows, so wrap the single row in an array
            const rowsToAppend = [row]
            result = await writer.appendRows(rowsToAppend).getResult()
            }
        return {
            streamId: connection.getStreamId(),
            protoDescriptor,
            result
            }
        }
    catch (e) {throw e}
    finally{
        if(writerClient){writerClient.close()}
        }
    }

El código de demostración también incluye un extremo de "estado" para solucionar problemas, pero este extremo no es obligatorio para la integración de la API de Action.

Guía de implementación

Por último, proporcionaremos una guía paso a paso para implementar la demostración por tu cuenta, que abarcará los requisitos previos, la implementación de la función de Cloud Run, la configuración de BigQuery y la configuración de Looker.

Requisitos previos del proyecto y del servicio

Antes de comenzar a configurar cualquier detalle, revisa esta lista para comprender qué servicios y políticas necesitará la solución:

  1. Un proyecto nuevo: Necesitarás un proyecto nuevo para alojar los recursos de nuestro ejemplo.
  2. Servicios: Cuando uses por primera vez las funciones de BigQuery y Cloud Run en la IU de Cloud Console, se te pedirá que habilites las APIs requeridas para los servicios necesarios, incluidos BigQuery, Artifact Registry, Cloud Build, Cloud Functions, Cloud Logging, Pub/Sub, Cloud Run Admin y Secret Manager.
  3. Política para invocaciones sin autenticar: Este caso de uso requiere que implementemos Cloud Run Functions que "permitan invocaciones sin autenticar", ya que controlaremos la autenticación de las solicitudes entrantes en nuestro código según la API de Actions, en lugar de usar IAM. Si bien esto se permite de forma predeterminada, la política de la organización suele restringir este uso. Específicamente, la política constraints/iam.allowedPolicyMemberDomains restringe quién puede recibir permisos de IAM, y es posible que debas ajustarla para permitir la principal allUsers para el acceso no autenticado. Consulta esta guía, Cómo crear servicios públicos de Cloud Run cuando se aplica el uso compartido restringido del dominio, para obtener más información si no puedes permitir invocaciones sin autenticar.
  4. Otras políticas: Ten en cuenta que otras Google Cloud restricciones de políticas de la organización también pueden impedir la implementación de servicios que, de otro modo, se permiten de forma predeterminada.

Implementa la Cloud Run Function

Una vez que hayas creado un proyecto nuevo, sigue estos pasos para implementar la función de Cloud Run:

  1. En Cloud Run Functions, haz clic en Crear función.
  2. Elige cualquier nombre para tu función (por ejemplo, "demo-bq-insert-action").
  3. En la configuración de Activador, haz lo siguiente:
    1. El tipo de activador ya debería ser "HTTPS".
    2. Establece Autenticación en Permitir invocaciones no autenticadas.
    3. Copia el valor de la URL en el portapapeles.
  4. En la configuración de Runtime > Runtime environment variables, haz lo siguiente:
    1. Haz clic en Agregar variable.
    2. Establece el nombre de la variable como CALLBACK_URL_PREFIX.
    3. Pega la URL del paso anterior como el valor.
  5. Haz clic en Siguiente.
  6. Haz clic en el archivo package.json y pega el contenido.
  7. Haz clic en el archivo index.js y pega el contenido.
  8. Asigna la variable projectId en la parte superior del archivo a tu propio ID del proyecto.
  9. Establece el punto de entrada en httpHandler.
  10. Haz clic en Implementar.
  11. Otorga los permisos solicitados (si hay alguno) a la cuenta de servicio de compilación.
  12. Espera a que se complete la implementación.
  13. Si, en algún paso futuro, recibes un error que te indica que revises los registros Google Cloud , ten en cuenta que puedes acceder a los registros de esta función desde la pestaña Registros de esta página.
  14. Antes de salir de la página de tu función de Cloud Run, en la pestaña Detalles, busca y anota la cuenta de servicio que tiene la función. Usaremos esto en pasos posteriores para asegurarnos de que la función tenga los permisos que necesita.
  15. Para probar la implementación de tu función directamente en el navegador, visita la URL. Deberías ver una respuesta JSON que contenga la ficha de tu integración.
  16. Si recibes un error 403, es posible que tu intento de configurar Permitir invocaciones no autenticadas haya fallado de forma silenciosa como resultado de una política de la organización. Comprueba si tu función permite invocaciones no autenticadas, revisa la configuración de la política de la organización y trata de actualizarla.

Acceso a la tabla de destino de BigQuery

En la práctica, la tabla de destino en la que se insertarán los datos puede residir en un proyecto Google Cloud diferente, pero, para fines de demostración, crearemos una tabla de destino nueva en nuestro mismo proyecto. En cualquier caso, deberás asegurarte de que la cuenta de servicio de tu Cloud Run Function tenga permisos para escribir en la tabla.

  1. Navega a la consola de BigQuery.
  2. Crea la tabla de demostración:

    1. En la barra del Explorador, usa el menú de puntos suspensivos junto a tu proyecto y selecciona Crear conjunto de datos.
    2. Asigna el ID demo_dataset a tu conjunto de datos y haz clic en Crear conjunto de datos.
    3. Usa el menú de puntos suspensivos en el conjunto de datos que acabas de crear y selecciona Crear tabla.
    4. Asigna a la tabla el nombre demo_table.
    5. En Esquema, selecciona Editar como texto, usa el siguiente esquema y, luego, haz clic en Crear tabla.

      [
       {"name":"invoked_at","type":"TIMESTAMP"},
       {"name":"invoked_by","type":"STRING"},
       {"name":"scheduled_plan_id","type":"STRING"},
       {"name":"query_result_size","type":"INTEGER"},
       {"name":"choice","type":"STRING"},
       {"name":"note","type":"STRING"}
      ]
      
  3. Asignar permisos:

    1. En la barra Explorador, haz clic en tu conjunto de datos.
    2. En la página del conjunto de datos, haz clic en Uso compartido > Permisos.
    3. Haz clic en Agregar principal.
    4. Establece el Principal nuevo en la cuenta de servicio de tu función, que se mencionó anteriormente en esta página.
    5. Asigna el rol de editor de datos de BigQuery.
    6. Haz clic en Guardar.

Cómo conectarse a Looker

Ahora que tu función está implementada, conectaremos Looker a ella.

  1. Necesitaremos un secreto compartido para que tu acción autentique que las solicitudes provienen de tu instancia de Looker. Genera una cadena aleatoria larga y mantenla segura. Lo usaremos en pasos posteriores como nuestro valor de secreto de Looker.
  2. En la consola de Cloud, navega a Secret Manager.
    1. Haz clic en Crear secreto.
    2. Configura el campo Nombre como LOOKER_SECRET. (Este valor está codificado de forma rígida en el código de esta demostración, pero puedes elegir cualquier nombre cuando trabajes con tu propio código).
    3. Establece el valor secreto en el valor secreto que generaste.
    4. Haz clic en Crear secreto.
    5. En la página Secreto, haz clic en la pestaña Permisos.
    6. Haz clic en Otorgar acceso.
    7. Establece New Principals en la cuenta de servicio de tu función, que anotaste anteriormente.
    8. Asigna el rol de Descriptor de acceso a secretos de Secret Manager.
    9. Haz clic en Guardar.
    10. Para confirmar que tu función accede correctamente al secreto, visita la ruta /status agregada a la URL de la función.
  3. En tu instancia de Looker, haz lo siguiente:
    1. Navega a Administrador > Plataforma > Acciones.
    2. Ve a la parte inferior de la página y haz clic en Agregar Action Hub.
    3. Proporciona la URL de tu función (por ejemplo, https://your-region-your-project.cloudfunctions.net/demo-bq-insert-action) y haz clic en Add Action Hub para confirmar.
    4. Ahora deberías ver una nueva entrada de Action Hub con una acción llamada Demo BigQuery Insert.
    5. En la entrada de Action Hub, haz clic en Configurar autorización.
    6. Ingresa tu secreto de Looker generado en el campo Token de autorización y haz clic en Actualizar token.
    7. En la acción Demo BigQuery Insert, haz clic en Habilitar.
    8. Activa el interruptor Habilitado.
    9. Se debería ejecutar automáticamente una prueba de la acción para confirmar que tu función acepta la solicitud de Looker y responde correctamente al extremo del formulario.
    10. Haz clic en Guardar.

Prueba de extremo a extremo

Ahora deberíamos poder usar nuestra nueva acción. Esta acción está configurada para funcionar con cualquier consulta, así que elige cualquier Explorar (por ejemplo, un Explorar de actividad del sistema integrado), agrega algunos campos a una consulta nueva, ejecútala y, luego, elige Enviar en el menú de ajustes. Deberías ver la acción como uno de los destinos disponibles y se te solicitarán algunas entradas de campo:

Captura de pantalla del modal "Enviar" de Looker con nuestra nueva acción seleccionada

Cuando presiones Enviar, deberías tener una fila nueva insertada en tu tabla de BigQuery (y el correo electrónico de tu cuenta de usuario de Looker identificado en la columna invoked_by).