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

Muchos clientes de Looker desean que sus usuarios puedan ir más allá de generar informes sobre los datos de su almacén de datos y, en realidad, volver a escribir en él y actualizarlo.

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 las funciones de Cloud Run y volver a 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 alinee con tus necesidades.

  • Funciones de Cloud Run
    • ¿Por qué usar Cloud Run? Como oferta "sin servidores" de Google, las funciones de Cloud Run son una excelente opción para facilitar las operaciones y el 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: Las funciones de Cloud Run admiten varios lenguajes y entornos de ejecución. En esta página de documentación, se analizará un ejemplo en JavaScript y Node.js. Sin embargo, los conceptos se pueden traducir directamente a los otros lenguajes y tiempos de ejecución admitidos.
  • BigQuery
    • ¿Por qué usar BigQuery? Aunque en esta página de documentación se da por sentado que ya usas BigQuery, esta 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 sentencias del lenguaje de manipulación de datos (DML) en trabajos basados en SQL. Sin embargo, la mejor opción para las operaciones de escritura de alto volumen es la API de BigQuery Storage Write.
      • Agregar en lugar de actualizar: Aunque esta solución solo agregará filas, no las actualizará, siempre puedes derivar tablas de "estado actual" en el momento de la consulta de un registro de solo agregar, lo que simula las actualizaciones.
  • Servicios de asistencia
    • Secret Manager: Secret Manager contiene valores secretos para asegurarse de que no se almacenen en lugares de acceso excesivo, como directamente en la configuración de la función.
    • Administración de identidades y accesos (IAM): IAM autoriza a la función a acceder al secreto necesario durante el tiempo de ejecución y a escribir en la tabla de BigQuery prevista.
    • Cloud Build: Aunque no se analizará en detalle en esta página, Cloud Build se usa en segundo plano en Cloud Run Functions. Puedes usar Cloud Build para automatizar las actualizaciones implementadas de forma continua en tus funciones a partir de cambios en tu código fuente en un repositorio de Git.
  • Acción y autenticación de 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 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 a la función para que actualice los datos en BigQuery con una cuenta de servicio.
    • OAuth: Otra opción, que no se aborda en esta página, es 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 una 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 nuestra acción de demostración disponible en GitHub. En esta sección, repasaremos 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 escribirá la acción. En la sección Guía de implementación, 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" mediante el módulo de Node.js de Secret Manager. Sin embargo, también puedes eliminar esta dependencia de código con la función integrada de las funciones 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 funciones de Cloud Run es una función de JavaScript exportada que sigue las convenciones del servidor web Express de Node.js. En particular, tu función recibe dos argumentos: el primero representa la solicitud HTTP, desde la que 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. Aunque el nombre de la función puede ser el que desees, deberás proporcionarlo a las funciones de Cloud Run más adelante, como se detalla en la sección 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 diversas rutas que reconocerá nuestra acción, reflejando 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 funciones de Cloud Run implementan una función independiente para cada una de esas rutas para alinearse de forma individual con el enrutamiento predeterminado de las funciones de Cloud Run, las funciones pueden aplicar "enrutamiento secundario" adicional dentro de su código, como se muestra aquí. En última instancia, esto es una cuestión de preferencia, pero hacer 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 del controlador HTTP implementa el control de la solicitud HTTP en función de las declaraciones de ruta anteriores y conecta los valores que muestran 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.")
        }
    }

Ahora que ya tenemos el controlador HTTP y las declaraciones de ruta, analizaremos los tres extremos de acción principales que tenemos que implementar:

Extremo de la lista de acciones

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

En nuestras declaraciones de ruta que mostramos anteriormente, hicimos que este extremo esté disponible en la ruta de acceso raíz (/) en la URL de nuestra función y le indicamos que la función hubListing lo controlaría.

Como puedes ver en la siguiente definición de función, no tiene demasiado “código” en absoluto, solo muestra los mismos datos JSON cada vez. Una cosa que debes 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}
                    ]
                }
            ]
        }
    }

A modo 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.

Ten en cuenta también que nuestra función de Cloud Run podría exponer y controlar varias acciones, lo que explica nuestra convención de ruta de /action-X/.... Sin embargo, nuestra función de Cloud Run 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 ajusta bien al caso de uso de las notificaciones de escritura en la base de datos, ya que los usuarios pueden inspeccionar los datos en Looker y, luego, proporcionar valores para insertar en la base de datos. Como nuestra lista de acciones proporcionó un parámetro form_url, Looker invocará este extremo del formulario de acción cuando un usuario comience a interactuar con tu acción para determinar qué datos adicionales capturar del usuario.

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

Configuramos nuestras declaraciones de ruta para permitir varios controladores como este porque se puede volver a usar cierta lógica 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 esperado del Secret Manager y rechaza todas las solicitudes que no tengan ese valor de 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 la filtración de información de sincronización del canal lateral que permitiría que un atacante descubra rápidamente el valor de nuestro secreto.

Si una solicitud pasa la verificación de autenticación de la instancia, el controlador action0Form la controla.

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"}
        ]
    }

Aunque 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 ejecución de acciones es donde se encuentra la mayor parte de la lógica de cualquier acción y donde analizaremos la lógica específica del caso de uso de inserción de BigQuery.

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

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

La función action0Execute comienza mostrando ejemplos de extracción de 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 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 fichas y formularios.

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. Sin embargo, para responder a interacciones individuales de los usuarios en el contexto de una función de Cloud Run, 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 "status" 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, te proporcionaremos una guía paso a paso para que implementes la demostración por tu cuenta, en la que se incluyen 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 los detalles, 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 las funciones de BigQuery y Cloud Run por primera vez en la IU de la consola de Cloud, se te pedirá que habilites las APIs necesarias para los servicios necesarios, como BigQuery, Artifact Registry, Cloud Build, Cloud Functions, Cloud Logging, Pub/Sub, Cloud Run Admin y Secret Manager.
  3. Política para invocaciones no autenticadas: Este caso de uso requiere que implementemos funciones de Cloud Run que "permitan invocaciones no autenticadas", ya que controlaremos la autenticación de las solicitudes entrantes en nuestro código según la API de Action, 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 de constraints/iam.allowedPolicyMemberDomains restringe a quién se le pueden otorgar permisos de IAM, y es posible que debas ajustarla para permitir que el principal allUsers tenga 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 restricciones de la política de la organizaciónGoogle Cloud también pueden impedir la implementación de servicios que, de otro modo, se permiten de forma predeterminada.

Implementa la función de Cloud Run

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

  1. En Funciones de Cloud Run, 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 Entorno de ejecución > Variables de entorno de ejecución, 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 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 de proyecto.
  9. Establece el Punto de entrada en httpHandler.
  10. Haz clic en Implementar.
  11. Otorga los permisos solicitados (si los hay) 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 Google Cloud registros, 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. Lo usaremos en pasos posteriores para asegurarnos de que la función tenga los permisos que necesita.
  15. Visita la URL para probar la implementación de tu función directamente en el navegador. 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 tu organización y, luego, intenta actualizar la configuración.

Acceso a la tabla de destino de BigQuery

En la práctica, la tabla de destino en la que se insertará puede residir en un proyecto Google Cloud diferente, pero, a modo 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 función de Cloud Run 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 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 el nombre demo_table a tu tabla.
    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 Compartir > Permisos.
    3. Haz clic en Agregar principal.
    4. Establece el principal nuevo en la cuenta de servicio de tu función, que se anotó antes en esta página.
    5. Asigna el rol Editor de datos de BigQuery.
    6. Haz clic en Guardar.

Cómo conectarse a Looker

Ahora que se implementó tu función, 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 Secret.
    2. Configura el campo Nombre como LOOKER_SECRET. (Este valor está codificado 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 Secret.
    5. En la página Secreto, haz clic en la pestaña Permisos.
    6. Haz clic en Otorgar acceso.
    7. Establece Principales nuevos en la cuenta de servicio de tu función, que anotaste antes.
    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 que se agregó 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 Agregar 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 el Secreto de Looker generado en el campo Authorization Token y haz clic en Update Token.
    7. En la acción Demo BigQuery Insert, haz clic en Habilitar.
    8. Activa el interruptor Habilitado.
    9. Se debe ejecutar automáticamente una prueba de la acción, lo que 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 exploración (por ejemplo, una exploración de actividad del sistema integrada), 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 cuadro modal "Enviar" de Looker con nuestra nueva acción seleccionada

Cuando presiones Enviar, se insertará una fila nueva en tu tabla de BigQuery (y el correo electrónico de tu cuenta de usuario de Looker se identificará en la columna invoked_by).